feat: initial datastructure #3

Merged
tim merged 4 commits from datastructure into prod 2025-05-04 14:17:13 +00:00
27 changed files with 1223 additions and 519 deletions

View File

@@ -1,98 +1,12 @@
# Web-App-Template
# SpendSparrow
A basic template with authentication to easily host on a VPC.
SpendSparrow is a web app to keep track of expenses and income. It is very opinionated by keeping an keen eye on disciplin of it's users. Every Expense needs to be mapped to a Piggy Bank. For emergencies, funds can be moved between Piggy Banks.
## Features
## Design priciples
This template includes everything essential to build an app. It includes the following features:
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
- SSL: This is included by using traefik as reverse proxy. It handles SSL certificates automatically. Furthermore all services are accessible through subdomains.
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
## Architecture Design Decisions
### Authentication
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
There are a few restrictions I would like to contain:
- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
- It should still be possible to run on a small VPC (2vcpu, 2GB).
- It should be as secure as possible
I determined 4 options:
1. Implement the authentication myself
2. Using OAuth2 with Keycloak
3. Using OAuth2 with Google and Apple
4. Firebase, Clerk, etc.
#### 1. Implement the authentication myself
It's always possible to implement it myself. The topic of authentication is something special though.
Pros:
- Great Cheat cheets from OWASP
- No adittional configuration or services needed
- Great learning experience on the topic "security"
Cons:
- Great attack vector
- Introcution of vlunerabillities is possible
- No DDOS protection
#### 2. Using OAuth2 with Google and Apple
Instead of implementing authentication from scratch, an external OAuth2 provider is embedded into the application.
Pros:
- The Systems of BigTech are probably safer. They have security experts employed.
- The other external system is responsible to prevent credential stuffing attacks, etc.
- Users don't have to create new credentials
Cons:
- High dependency on those providers
- Single Point of failure (If your account is banned, your application access get's lost as well.)
- It's possible that these providers ban the whole application (All users lose access)
- There still needs to be implemented some logic
- Full application integration can be difficult
#### 3. Using OAuth2 with Keycloak
This option is almost identical with the previois one, but the provider is self hosted.
Pros:
- Indipendent from 3rd party providers
- The credentials are stored safly
Cons:
- Self hosted (no DDOS protection, etc.)
- There still needs to be implemented some logic server side
- Full application integration can be difficult
#### 4. Firebase, Clerk, etc.
Users can sign in with a seperate sdk on your website
Pros:
- Safe and Sound authentication
Cons:
- Dependent on those providers / adittional setup needed
- Application can be banned
- Still some integration code needed
#### Decision
I've decided on implementing authentication myself, as this is a great learning opportunity. It may not be as secure as other solutions, but if I keep tighly to the OWASP recommendations, it should should good enough.
### Email
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.
The State of the application can always be calculated on the fly. Even though it is not an Event Streaming Application, it is still important to be able to recalculate historic data.
It may be applicable to do some sort of monthly snapshots to speed up calculations, but this will be only done if database queries become a bottleneck.
This applications uses as little dependencies as feasible, especially on the front end.

118
db/account.go Normal file
View File

@@ -0,0 +1,118 @@
package db
import (
"spend-sparrow/log"
"spend-sparrow/types"
"github.com/jmoiron/sqlx"
"github.com/google/uuid"
)
type Account interface {
Insert(account *types.Account) error
Update(account *types.Account) error
GetAll(groupId uuid.UUID) ([]*types.Account, error)
Get(groupId uuid.UUID, id uuid.UUID) (*types.Account, error)
Delete(id uuid.UUID) error
}
type AccountSqlite struct {
db *sqlx.DB
}
func NewAccountSqlite(db *sqlx.DB) *AccountSqlite {
return &AccountSqlite{db: db}
}
func (db AccountSqlite) Insert(account *types.Account) error {
_, err := db.db.Exec(`
INSERT INTO account (id, group_id, name, current_balance, oink_balance, created_at, created_by)
VALUES (?,?,?,?,?,?,?)`, account.Id, account.GroupId, 0, 0, account.CreatedAt, account.CreatedBy)
if err != nil {
log.Error("Error inserting account: %v", err)
return types.ErrInternal
}
return nil
}
func (db AccountSqlite) Update(account *types.Account) error {
_, err := db.db.Exec(`
UPDATE account
name = ?,
current_balance = ?,
last_transaction = ?,
oink_balance = ?,
updated_at = ?,
updated_by = ?,
WHERE id = ?
AND group_id = ?`, account.Name, account.CurrentBalance, account.LastTransaction, account.OinkBalance, account.UpdatedAt, account.UpdatedBy, account.Id, account.GroupId)
if err != nil {
log.Error("Error updating account: %v", err)
return types.ErrInternal
}
return nil
}
func (db AccountSqlite) GetAll(groupId uuid.UUID) ([]*types.Account, error) {
accounts := make([]*types.Account, 0)
err := db.db.Select(&accounts, `
SELECT
id, name,
current_balance, last_transaction, oink_balance,
created_at, created_by, updated_at, updated_by
FROM account
WHERE group_id = ?
ORDER BY name`, groupId)
if err != nil {
log.Error("Could not getAll accounts: %v", err)
return nil, types.ErrInternal
}
return accounts, nil
}
func (db AccountSqlite) Get(groupId uuid.UUID, id uuid.UUID) (*types.Account, error) {
account := &types.Account{}
err := db.db.Get(account, `
SELECT
id, name,
current_balance, last_transaction, oink_balance,
created_at, created_by, updated_at, updated_by
FROM account
WHERE group_id = ?
AND id = ?`, groupId, id)
if err != nil {
log.Error("Could not get accounts: %v", err)
return nil, types.ErrInternal
}
return account, nil
}
func (db AccountSqlite) Delete(id uuid.UUID) error {
res, err := db.db.Exec("DELETE FROM account WHERE id = ?", id)
if err != nil {
log.Error("Error deleting account: %v", err)
return types.ErrInternal
}
rows, err := res.RowsAffected()
if err != nil {
log.Error("Error deleting account, getting rows affected: %v", err)
return types.ErrInternal
}
if rows == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -5,16 +5,11 @@ import (
"spend-sparrow/types"
"database/sql"
"errors"
"strings"
"time"
"github.com/google/uuid"
)
var (
ErrNotFound = errors.New("value not found")
ErrAlreadyExists = errors.New("row already exists")
"github.com/jmoiron/sqlx"
)
type Auth interface {
@@ -38,10 +33,10 @@ type Auth interface {
}
type AuthSqlite struct {
db *sql.DB
db *sqlx.DB
}
func NewAuthSqlite(db *sql.DB) *AuthSqlite {
func NewAuthSqlite(db *sqlx.DB) *AuthSqlite {
return &AuthSqlite{db: db}
}
@@ -140,10 +135,10 @@ func (db AuthSqlite) DeleteUser(userId uuid.UUID) error {
return types.ErrInternal
}
_, err = tx.Exec("DELETE FROM workout WHERE user_id = ?", userId)
_, err = tx.Exec("DELETE FROM account WHERE group_id = ?", userId)
if err != nil {
_ = tx.Rollback()
log.Error("Could not delete workouts: %v", err)
log.Error("Could not delete accounts: %v", err)
return types.ErrInternal
}

View File

@@ -1,17 +1,17 @@
package db
import (
"database/sql"
"spend-sparrow/types"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
)
func setupDb(t *testing.T) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
func setupDb(t *testing.T) *sqlx.DB {
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Error opening database: %v", err)
}

View File

@@ -4,16 +4,21 @@ import (
"spend-sparrow/log"
"spend-sparrow/types"
"database/sql"
"errors"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jmoiron/sqlx"
)
func RunMigrations(db *sql.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
var (
ErrNotFound = errors.New("The value does not exist.")
ErrAlreadyExists = errors.New("row already exists")
)
func RunMigrations(db *sqlx.DB, pathPrefix string) error {
driver, err := sqlite3.WithInstance(db.DB, &sqlite3.Config{})
if err != nil {
log.Error("Could not create Migration instance: %v", err)
return types.ErrInternal
@@ -38,4 +43,3 @@ func RunMigrations(db *sql.DB, pathPrefix string) error {
return nil
}

View File

@@ -1,119 +0,0 @@
package db
import (
"spend-sparrow/log"
"spend-sparrow/types"
"database/sql"
"errors"
"time"
"github.com/google/uuid"
)
var (
ErrWorkoutNotExists = errors.New("Workout does not exist")
)
type WorkoutDb interface {
InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error)
GetWorkouts(userId uuid.UUID) ([]Workout, error)
DeleteWorkout(userId uuid.UUID, rowId int) error
}
type WorkoutDbSqlite struct {
db *sql.DB
}
func NewWorkoutDbSqlite(db *sql.DB) *WorkoutDbSqlite {
return &WorkoutDbSqlite{db: db}
}
type WorkoutInsert struct {
Date time.Time
Type string
Sets int
Reps int
}
type Workout struct {
RowId int
Date time.Time
Type string
Sets int
Reps int
}
func NewWorkoutInsert(date time.Time, workoutType string, sets int, reps int) *WorkoutInsert {
return &WorkoutInsert{Date: date, Type: workoutType, Sets: sets, Reps: reps}
}
func NewWorkoutFromInsert(rowId int, workoutInsert *WorkoutInsert) *Workout {
return &Workout{RowId: rowId, Date: workoutInsert.Date, Type: workoutInsert.Type, Sets: workoutInsert.Sets, Reps: workoutInsert.Reps}
}
func (db WorkoutDbSqlite) InsertWorkout(userId uuid.UUID, workout *WorkoutInsert) (*Workout, error) {
var rowId int
err := db.db.QueryRow(`
INSERT INTO workout (user_id, date, type, sets, reps)
VALUES (?, ?, ?, ?, ?)
RETURNING rowid`, userId, workout.Date, workout.Type, workout.Sets, workout.Reps).Scan(&rowId)
if err != nil {
log.Error("Error inserting workout: %v", err)
return nil, types.ErrInternal
}
return NewWorkoutFromInsert(rowId, workout), nil
}
func (db WorkoutDbSqlite) GetWorkouts(userId uuid.UUID) ([]Workout, error) {
rows, err := db.db.Query("SELECT rowid, date, type, sets, reps FROM workout WHERE user_id = ? ORDER BY date desc", userId)
if err != nil {
log.Error("Could not get workouts: %v", err)
return nil, types.ErrInternal
}
var workouts = make([]Workout, 0)
for rows.Next() {
var (
workout Workout
date string
)
err = rows.Scan(&workout.RowId, &date, &workout.Type, &workout.Sets, &workout.Reps)
if err != nil {
log.Error("Could not scan workout: %v", err)
return nil, types.ErrInternal
}
workout.Date, err = time.Parse("2006-01-02 15:04:05-07:00", date)
if err != nil {
log.Error("Could not parse date: %v", err)
return nil, types.ErrInternal
}
workouts = append(workouts, workout)
}
return workouts, nil
}
func (db WorkoutDbSqlite) DeleteWorkout(userId uuid.UUID, rowId int) error {
res, err := db.db.Exec("DELETE FROM workout WHERE user_id = ? AND rowid = ?", userId, rowId)
if err != nil {
return types.ErrInternal
}
rows, err := res.RowsAffected()
if err != nil {
return types.ErrInternal
}
if rows == 0 {
return ErrWorkoutNotExists
}
return nil
}

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/a-h/templ v0.3.865
github.com/golang-migrate/migrate/v4 v4.18.3
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.28
github.com/prometheus/client_golang v1.22.0

2
go.sum
View File

@@ -17,6 +17,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=

126
handler/account.go Normal file
View File

@@ -0,0 +1,126 @@
package handler
import (
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
"spend-sparrow/template/account"
"spend-sparrow/utils"
"net/http"
)
type Account interface {
Handle(router *http.ServeMux)
}
type AccountImpl struct {
service service.Account
auth service.Auth
render *Render
}
func NewAccount(service service.Account, auth service.Auth, render *Render) Account {
return AccountImpl{
service: service,
auth: auth,
render: render,
}
}
func (handler AccountImpl) Handle(router *http.ServeMux) {
router.Handle("/account", handler.handleAccountPage())
// router.Handle("POST /account", handler.handleAddAccount())
// router.Handle("GET /account", handler.handleGetAccount())
// router.Handle("DELETE /account/{id}", handler.handleDeleteAccount())
}
func (handler AccountImpl) handleAccountPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
comp := account.AccountListComp(nil)
handler.render.RenderLayout(r, w, comp, user)
}
}
// func (handler AccountImpl) handleAddAccount() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// user := middleware.GetUser(r)
// if user == nil {
// utils.DoRedirect(w, r, "/auth/signin")
// return
// }
//
// var dateStr = r.FormValue("date")
// var typeStr = r.FormValue("type")
// var setsStr = r.FormValue("sets")
// var repsStr = r.FormValue("reps")
//
// wo := service.NewAccountDto("", dateStr, typeStr, setsStr, repsStr)
// wo, err := handler.service.AddAccount(user, wo)
// if err != nil {
// utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
// http.Error(w, "Invalid input values", http.StatusBadRequest)
// return
// }
// wor := account.Account{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps}
//
// comp := account.AccountItemComp(wor, true)
// handler.render.Render(r, w, comp)
// }
// }
//
// func (handler AccountImpl) handleGetAccount() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// user := middleware.GetUser(r)
// if user == nil {
// utils.DoRedirect(w, r, "/auth/signin")
// return
// }
//
// workouts, err := handler.service.GetAccounts(user)
// if err != nil {
// return
// }
//
// wos := make([]*types.Account, 0)
// for _, wo := range workouts {
// wos = append(wos, *types.Account{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps})
// }
//
// comp := account.AccountListComp(wos)
// handler.render.Render(r, w, comp)
// }
// }
//
// func (handler AccountImpl) handleDeleteAccount() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// user := middleware.GetUser(r)
// if user == nil {
// utils.DoRedirect(w, r, "/auth/signin")
// return
// }
//
// rowId := r.PathValue("id")
// if rowId == "" {
// utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
// return
// }
//
// rowIdInt, err := strconv.Atoi(rowId)
// if err != nil {
// utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
// return
// }
//
// err = handler.service.DeleteAccount(user, rowIdInt)
// if err != nil {
// utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
// return
// }
// }
// }

View File

@@ -1,129 +0,0 @@
package handler
import (
"spend-sparrow/handler/middleware"
"spend-sparrow/service"
"spend-sparrow/template/workout"
"spend-sparrow/utils"
"net/http"
"strconv"
"time"
)
type Workout interface {
Handle(router *http.ServeMux)
}
type WorkoutImpl struct {
service service.Workout
auth service.Auth
render *Render
}
func NewWorkout(service service.Workout, auth service.Auth, render *Render) Workout {
return WorkoutImpl{
service: service,
auth: auth,
render: render,
}
}
func (handler WorkoutImpl) Handle(router *http.ServeMux) {
router.Handle("/workout", handler.handleWorkoutPage())
router.Handle("POST /api/workout", handler.handleAddWorkout())
router.Handle("GET /api/workout", handler.handleGetWorkout())
router.Handle("DELETE /api/workout/{id}", handler.handleDeleteWorkout())
}
func (handler WorkoutImpl) handleWorkoutPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
currentDate := time.Now().Format("2006-01-02")
comp := workout.WorkoutComp(currentDate)
handler.render.RenderLayout(r, w, comp, user)
}
}
func (handler WorkoutImpl) handleAddWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
var dateStr = r.FormValue("date")
var typeStr = r.FormValue("type")
var setsStr = r.FormValue("sets")
var repsStr = r.FormValue("reps")
wo := service.NewWorkoutDto("", dateStr, typeStr, setsStr, repsStr)
wo, err := handler.service.AddWorkout(user, wo)
if err != nil {
utils.TriggerToast(w, r, "error", "Invalid input values", http.StatusBadRequest)
http.Error(w, "Invalid input values", http.StatusBadRequest)
return
}
wor := workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps}
comp := workout.WorkoutItemComp(wor, true)
handler.render.Render(r, w, comp)
}
}
func (handler WorkoutImpl) handleGetWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
workouts, err := handler.service.GetWorkouts(user)
if err != nil {
return
}
wos := make([]workout.Workout, 0)
for _, wo := range workouts {
wos = append(wos, workout.Workout{Id: wo.RowId, Date: wo.Date, Type: wo.Type, Sets: wo.Sets, Reps: wo.Reps})
}
comp := workout.WorkoutListComp(wos)
handler.render.Render(r, w, comp)
}
}
func (handler WorkoutImpl) handleDeleteWorkout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
utils.DoRedirect(w, r, "/auth/signin")
return
}
rowId := r.PathValue("id")
if rowId == "" {
utils.TriggerToast(w, r, "error", "Missing ID field", http.StatusBadRequest)
return
}
rowIdInt, err := strconv.Atoi(rowId)
if err != nil {
utils.TriggerToast(w, r, "error", "Invalid ID", http.StatusBadRequest)
return
}
err = handler.service.DeleteWorkout(user, rowIdInt)
if err != nil {
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

16
main.go
View File

@@ -9,7 +9,6 @@ import (
"spend-sparrow/types"
"context"
"database/sql"
"net/http"
"os"
"os/signal"
@@ -17,6 +16,7 @@ import (
"syscall"
"time"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -28,7 +28,7 @@ func main() {
log.Fatal("Error loading .env file")
}
db, err := sql.Open("sqlite3", "./data.db")
db, err := sqlx.Open("sqlite3", "./data.db")
if err != nil {
log.Fatal("Could not open Database data.db: %v", err)
}
@@ -37,7 +37,7 @@ func main() {
run(context.Background(), db, os.Getenv)
}
func run(ctx context.Context, database *sql.DB, env func(string) string) {
func run(ctx context.Context, database *sqlx.DB, env func(string) string) {
ctx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
@@ -100,26 +100,26 @@ func shutdownServer(s *http.Server, ctx context.Context, wg *sync.WaitGroup) {
}
}
func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
func createHandler(d *sqlx.DB, serverSettings *types.Settings) http.Handler {
var router = http.NewServeMux()
authDb := db.NewAuthSqlite(d)
workoutDb := db.NewWorkoutDbSqlite(d)
accountDb := db.NewAccountSqlite(d)
randomService := service.NewRandomImpl()
clockService := service.NewClockImpl()
mailService := service.NewMailImpl(serverSettings)
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
accountService := service.NewAccountImpl(accountDb, randomService, clockService, serverSettings)
render := handler.NewRender()
indexHandler := handler.NewIndex(authService, render)
authHandler := handler.NewAuth(authService, render)
workoutHandler := handler.NewWorkout(workoutService, authService, render)
accountHandler := handler.NewAccount(accountService, authService, render)
indexHandler.Handle(router)
workoutHandler.Handle(router)
accountHandler.Handle(router)
authHandler.Handle(router)
// Serve static files (CSS, JS and images)

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
@@ -15,6 +14,7 @@ import (
"spend-sparrow/types"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
@@ -928,7 +928,7 @@ func TestIntegrationAuth(t *testing.T) {
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ?", userId).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 0, rows)
err = db.QueryRow("SELECT COUNT(*) FROM workout WHERE user_id = ?", userId).Scan(&rows)
err = db.QueryRow("SELECT COUNT(*) FROM account WHERE group_id = ?", userId).Scan(&rows)
assert.Nil(t, err)
assert.Equal(t, 0, rows)
})
@@ -1602,7 +1602,7 @@ func TestIntegrationAuth(t *testing.T) {
VALUES (?, ?, datetime("now", "-8 hour"), datetime("now", "-1 minute"))`, sessionId, userId)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/workout", nil)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/account", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
@@ -1624,11 +1624,11 @@ func findCookie(resp *http.Response, name string) *http.Cookie {
return nil
}
func setupIntegrationTest(t *testing.T) (db *sql.DB, basePath string, ctx context.Context) {
func setupIntegrationTest(t *testing.T) (db *sqlx.DB, basePath string, ctx context.Context) {
ctx, done := context.WithCancel(context.Background())
t.Cleanup(done)
db, err := sql.Open("sqlite3", ":memory:")
db, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Could not open Database data.db: %v", err)
}

View File

@@ -35,10 +35,3 @@ CREATE TABLE token (
expires_at DATETIME
);
CREATE TABLE workout (
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
type TEXT NOT NULL,
sets INTEGER NOT NULL,
reps INTEGER NOT NULL
);

View File

@@ -0,0 +1,17 @@
CREATE TABLE account (
id TEXT NOT NULL UNIQUE PRIMARY KEY,
group_id TEXT NOT NULL,
name TEXT NOT NULL,
current_balance int64 NOT NULL,
last_transaction DATETIME,
oink_balance int64 NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL,
updated_at DATETIME,
updated_by TEXT
) WITHOUT ROWID;

158
service/account.go Normal file
View File

@@ -0,0 +1,158 @@
package service
import (
"errors"
"regexp"
"spend-sparrow/db"
"spend-sparrow/log"
"spend-sparrow/types"
"github.com/google/uuid"
)
var (
safeInputRegex = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
)
type Account interface {
Add(user *types.User, name string) (*types.Account, error)
Update(user *types.User, id uuid.UUID, name string) (*types.Account, error)
Get(user *types.User) ([]*types.Account, error)
Delete(user *types.User, id uuid.UUID) error
}
type AccountImpl struct {
db db.Account
clock Clock
random Random
settings *types.Settings
}
func NewAccountImpl(db db.Account, random Random, clock Clock, settings *types.Settings) Account {
return AccountImpl{
db: db,
clock: clock,
random: NewRandomImpl(),
settings: settings,
}
}
func (service AccountImpl) Add(user *types.User, name string) (*types.Account, error) {
if user == nil {
return nil, types.ErrInternal
}
newId, err := service.random.UUID()
if err != nil {
return nil, types.ErrInternal
}
err = service.validateAccount(name)
if err != nil {
return nil, err
}
account := &types.Account{
Id: newId,
GroupId: user.Id,
Name: name,
CurrentBalance: 0,
LastTransaction: nil,
OinkBalance: 0,
CreatedAt: service.clock.Now(),
CreatedBy: user.Id,
UpdatedAt: nil,
UpdatedBy: nil,
}
err = service.db.Insert(account)
if err != nil {
return nil, err
}
savedAccount, err := service.db.Get(user.Id, newId)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
log.Error("Account not found after insert: %v", err)
}
return nil, types.ErrInternal
}
return savedAccount, nil
}
func (service AccountImpl) Update(user *types.User, id uuid.UUID, name string) (*types.Account, error) {
if user == nil {
return nil, types.ErrInternal
}
err := service.validateAccount(name)
if err != nil {
return nil, err
}
account, err := service.db.Get(user.Id, id)
if err != nil {
return nil, err
}
timestamp := service.clock.Now()
account.Name = name
account.UpdatedAt = &timestamp
account.UpdatedBy = &user.Id
err = service.db.Update(account)
if err != nil {
return nil, err
}
return account, nil
}
func (service AccountImpl) Get(user *types.User) ([]*types.Account, error) {
if user == nil {
return nil, types.ErrInternal
}
accounts, err := service.db.GetAll(user.GroupId)
if err != nil {
return nil, types.ErrInternal
}
return accounts, nil
}
func (service AccountImpl) Delete(user *types.User, id uuid.UUID) error {
if user == nil {
return types.ErrInternal
}
account, err := service.db.Get(user.GroupId, id)
if err != nil {
return err
}
if account.GroupId != user.GroupId {
return types.ErrUnauthorized
}
err = service.db.Delete(account.Id)
if err != nil {
return err
}
return nil
}
func (service AccountImpl) validateAccount(name string) error {
if name == "" {
return errors.New("Please enter a value for the \"name\" field.")
} else if !safeInputRegex.MatchString(name) {
return errors.New("Please use only letters, dashes or numbers for \"name\".")
} else {
return nil
}
}

8
service/money.go Normal file
View File

@@ -0,0 +1,8 @@
package service
type MoneyImpl struct {
}
func NewMoneyImpl() *MoneyImpl {
return &MoneyImpl{}
}

80
service/money_test.go Normal file
View File

@@ -0,0 +1,80 @@
package service
import (
"testing"
)
func TestMoneyCalculation(t *testing.T) {
t.Parallel()
t.Run("should calculate correct oink balance", func(t *testing.T) {
// t.Parallel()
//
// underTest := NewMoneyImpl()
//
// // GIVEN
// timestamp := time.Date(2020, 01, 01, 0, 0, 0, 0, time.UTC)
//
// groupId := uuid.New()
//
// account := types.Account{
// Id: uuid.New(),
// GroupId: groupId,
//
// Type: "Bank",
// Name: "Bank",
//
// CurrentBalance: 0,
// LastTransaction: time.Time{},
// OinkBalance: 0,
// }
//
// // The PiggyBank is a fictional account. The money it "holds" is actually in the Account
// piggyBank := types.PiggyBank{
// Id: uuid.New(),
// GroupId: groupId,
//
// AccountId: account.Id,
// Name: "Car",
//
// CurrentBalance: 0,
// }
//
// savingsPlan := types.SavingsPlan{
// Id: uuid.New(),
// GroupId: groupId,
// PiggyBankId: piggyBank.Id,
//
// MonthlySaving: 10,
//
// ValidFrom: timestamp,
// }
//
// transaction1 := types.Transaction{
// Id: uuid.New(),
// GroupId: groupId,
//
// AccountId: account.Id,
//
// Value: 20,
// Timestamp: timestamp,
// }
//
// transaction2 := types.Transaction{
// Id: uuid.New(),
// GroupId: groupId,
//
// AccountId: account.Id,
// PiggyBankId: &piggyBank.Id,
//
// Value: -1,
// Timestamp: timestamp.Add(1 * time.Hour),
// }
//
// // WHEN
// actual, err := underTest.CalculateAllBalancesInTime(account, piggyBank, savingsPlan, []types.Transaction{transaction1, transaction2})
//
// // THEN
// assert.Nil(t, err)
// assert.ElementsMatch(t, expected, actual)
})
}

View File

@@ -1,130 +0,0 @@
package service
import (
"spend-sparrow/db"
"spend-sparrow/types"
"errors"
"strconv"
"time"
)
type Workout interface {
AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error)
DeleteWorkout(user *types.User, rowId int) error
GetWorkouts(user *types.User) ([]*WorkoutDto, error)
}
type WorkoutImpl struct {
db db.WorkoutDb
random Random
clock Clock
mail Mail
settings *types.Settings
}
func NewWorkoutImpl(db db.WorkoutDb, random Random, clock Clock, mail Mail, settings *types.Settings) Workout {
return WorkoutImpl{
db: db,
random: random,
clock: clock,
mail: mail,
settings: settings,
}
}
type WorkoutDto struct {
RowId string
Date string
Type string
Sets string
Reps string
}
func NewWorkoutDtoFromDb(workout *db.Workout) *WorkoutDto {
return &WorkoutDto{
RowId: strconv.Itoa(workout.RowId),
Date: renderDate(workout.Date),
Type: workout.Type,
Sets: strconv.Itoa(workout.Sets),
Reps: strconv.Itoa(workout.Reps),
}
}
func NewWorkoutDto(rowId string, date string, workoutType string, sets string, reps string) *WorkoutDto {
return &WorkoutDto{
RowId: rowId,
Date: date,
Type: workoutType,
Sets: sets,
Reps: reps,
}
}
var (
ErrInputValues = errors.New("invalid input values")
)
func (service WorkoutImpl) AddWorkout(user *types.User, workoutDto *WorkoutDto) (*WorkoutDto, error) {
if workoutDto.Date == "" || workoutDto.Type == "" || workoutDto.Sets == "" || workoutDto.Reps == "" {
return nil, ErrInputValues
}
date, err := time.Parse("2006-01-02", workoutDto.Date)
if err != nil {
return nil, ErrInputValues
}
sets, err := strconv.Atoi(workoutDto.Sets)
if err != nil {
return nil, ErrInputValues
}
reps, err := strconv.Atoi(workoutDto.Reps)
if err != nil {
return nil, ErrInputValues
}
workoutInsert := db.NewWorkoutInsert(date, workoutDto.Type, sets, reps)
workout, err := service.db.InsertWorkout(user.Id, workoutInsert)
if err != nil {
return nil, err
}
return NewWorkoutDtoFromDb(workout), nil
}
func (service WorkoutImpl) DeleteWorkout(user *types.User, rowId int) error {
if user == nil {
return types.ErrInternal
}
return service.db.DeleteWorkout(user.Id, rowId)
}
func (service WorkoutImpl) GetWorkouts(user *types.User) ([]*WorkoutDto, error) {
if user == nil {
return nil, types.ErrInternal
}
workouts, err := service.db.GetWorkouts(user.Id)
if err != nil {
return nil, err
}
// for _, workout := range workouts {
// workout.Date = renderDate(workout.Date)
// }
workoutsDto := make([]*WorkoutDto, len(workouts))
for i, workout := range workouts {
workoutsDto[i] = NewWorkoutDtoFromDb(&workout)
}
return workoutsDto, nil
}
func renderDate(date time.Time) string {
return date.Format("2006-01-02")
}

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><path fill="currentColor" d="M165.906 18.688C15.593 59.28-42.187 198.55 92.72 245.375h-1.095c.635.086 1.274.186 1.906.28c8.985 3.077 18.83 5.733 29.532 7.94C173.36 273.35 209.74 321.22 212.69 368c-33.514 23.096-59.47 62.844-59.47 62.844l26.28 38.686L138.28 493h81.97c-40.425-40.435-11.76-85.906 36.125-85.906c48.54 0 73.945 48.112 36.156 85.906h81.126l-40.375-23.47l26.283-38.686s-26.376-40.4-60.282-63.406c3.204-46.602 39.5-94.167 89.595-113.844c10.706-2.207 20.546-4.86 29.53-7.938c.633-.095 1.273-.195 1.908-.28h-1.125c134.927-46.82 77.163-186.094-73.157-226.69c-40.722 39.37 6.54 101.683 43.626 56.877c36.9 69.08 8.603 127.587-72.28 83.406c-11.88 24.492-34.213 41.374-60.688 41.374c-26.703 0-49.168-17.167-60.97-42c-81.774 45.38-110.512-13.372-73.437-82.78c37.09 44.805 84.35-17.508 43.626-56.876zm90.79 35.92c-27.388 0-51.33 27.556-51.33 63.61c0 36.056 23.942 62.995 51.33 62.995s51.327-26.94 51.327-62.994c0-36.058-23.94-63.61-51.328-63.61z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M93.46 39.45c6.71-1.49 15.45-8.15 16.78-11.43.78-1.92-3.11-4.92-4.15-6.13-2.38-2.76-1.42-4.12-.5-7.41 1.05-3.74-1.44-7.87-4.97-9.49s-7.75-1.11-11.3.47-6.58 4.12-9.55 6.62c-2.17-1.37-5.63-7.42-11.23-3.49-3.87 2.71-4.22 8.61-3.72 13.32 1.17 10.87 3.85 16.51 8.9 18.03 6.38 1.92 13.44.91 19.74-.49" style="fill:#ffca28"/><path d="M104.36 8.18c-.85 14.65-15.14 24.37-21.92 28.65l4.4 3.78s2.79.06 6.61-1.16c6.55-2.08 16.12-7.96 16.78-11.43.97-5.05-4.21-3.95-5.38-7.94-.61-2.11 2.97-6.1-.49-11.9M79.78 12.09s-2.55-2.61-4.44-3.8c-.94 1.77-1.61 3.69-1.94 5.67-.59 3.48 0 8.42 1.39 12.1.22.57 1.04.48 1.13-.12 1.2-7.91 3.86-13.85 3.86-13.85" style="fill:#e2a610"/><path d="M61.96 38.16S30.77 41.53 16.7 68.61s-2.11 43.5 10.55 49.48 44.56 8.09 65.31 3.17 25.94-15.12 24.97-24.97c-1.41-14.38-14.77-23.22-14.77-23.22s.53-17.76-13.25-29.29c-12.23-10.24-27.55-5.62-27.55-5.62" style="fill:#ffca28"/><path d="M74.76 83.73c-6.69-8.44-14.59-9.57-17.12-12.6-1.38-1.65-2.19-3.32-1.88-5.39.33-2.2 2.88-3.72 4.86-4.09 2.31-.44 7.82-.21 12.45 4.2 1.1 1.04.7 2.66.67 4.11-.08 3.11 4.37 6.13 7.97 3.53 3.61-2.61.84-8.42-1.49-11.24-1.76-2.13-8.14-6.82-16.07-7.56-2.23-.21-11.2-1.54-16.38 8.31-1.49 2.83-2.04 9.67 5.76 15.45 1.63 1.21 10.09 5.51 12.44 8.3 4.07 4.83 1.28 9.08-1.9 9.64-8.67 1.52-13.58-3.17-14.49-5.74-.65-1.83.03-3.81-.81-5.53-.86-1.77-2.62-2.47-4.48-1.88-6.1 1.94-4.16 8.61-1.46 12.28 2.89 3.93 6.44 6.3 10.43 7.6 14.89 4.85 22.05-2.81 23.3-8.42.92-4.11.82-7.67-1.8-10.97" style="fill:#6b4b46"/><path d="M71.16 48.99c-12.67 27.06-14.85 61.23-14.85 61.23" style="fill:none;stroke:#6b4b46;stroke-width:5;stroke-miterlimit:10"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6d4c41"/><path d="M81.67 31.96c8.44 2.75 10.31 10.38 9.7 12.46-.73 2.44-10.08-7.06-23.98-6.49-4.86.2-3.45-2.78-1.2-4.5 2.97-2.27 7.96-3.91 15.48-1.47" style="fill:#6b4b46"/><path d="M96.49 58.86c1.06-.73 4.62.53 5.62 7.5.49 3.41.64 6.71.64 6.71s-4.2-3.77-5.59-6.42c-1.75-3.35-2.43-6.59-.67-7.79" style="fill:#e2a610"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
package account

View File

@@ -0,0 +1,7 @@
package account
import "spend-sparrow/types"
templ AccountListComp(accounts []*types.Account) {
<main class="mx-2"></main>
}

View File

@@ -3,14 +3,7 @@ package template
templ Index() {
<div class="h-full">
<div class="text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Next Level Workout Tracker</h1>
<p class="py-6">
Ever wanted to track your workouts and see your progress over time? spend-sparrow is the perfect
solution for you.
</p>
<a href="/workout" class="">Get Started</a>
</div>
<h1 class="text-5xl font-bold">Spend Sparrow - Your personal finance</h1>
</div>
</div>
}

View File

@@ -24,8 +24,8 @@ templ Layout(slot templ.Component, user templ.Component) {
<div class="h-screen flex flex-col">
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow-sm">
<a href="/" class="flex-1 flex gap-2">
<img src="/static/favicon.svg" alt="spend-sparrow logo"/>
<span>spend-sparrow</span>
<img class="w-6" src="/static/favicon.svg" alt="Spend Sparrow logo"/>
<span class="text-xl font-bold">SpendSparrow</span>
</a>
@user
</div>

572
test.log Normal file
View File

@@ -0,0 +1,572 @@
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1025"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1034"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1033"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1029"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1027"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1028"
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1032"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1026"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1030"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server...
2025/05/04 16:09:05 INFO BASE_URL is "http://localhost:1031"
2025/05/04 16:09:05 INFO ENVIRONMENT is "test"
2025/05/04 16:09:05 INFO Starting server on ":1034"
2025/05/04 16:09:05 INFO Starting server on ":1029"
2025/05/04 16:09:05 INFO Starting server on ":1028"
2025/05/04 16:09:05 INFO Starting server on ":1025"
2025/05/04 16:09:05 INFO Starting server on ":1033"
2025/05/04 16:09:05 INFO Starting server on ":1030"
2025/05/04 16:09:05 INFO Starting server on ":1027"
2025/05/04 16:09:05 INFO Starting server on ":1031"
2025/05/04 16:09:05 INFO Starting server on ":1026"
2025/05/04 16:09:05 INFO Starting server on ":1032"
2025/05/04 16:09:06 INFO Anonymous session created: C7CpPA1q5HSqATguH4ITVcaREZPMp/j78y0ZXp2goj4=
2025/05/04 16:09:06 INFO Anonymous session created: GLqqnLTym8scPyB4V8fHjpHUTQBUHJrIoVO/byzly0Y=
2025/05/04 16:09:06 INFO Anonymous session created: gkCWUdyhAU3+m6mCGRjHMYtmvRVKHoZRXUlsOM2kBFI=
2025/05/04 16:09:06 INFO Anonymous session created: /p9htltva1j9WlwdthWyhzMKc99iYgmuqgVwV6S+qp0=
2025/05/04 16:09:06 INFO Anonymous session created: KfwxtvpSqFx+YcwXdh+s3zBuDlBRY80/Q+qJY4YkCvI=
2025/05/04 16:09:06 INFO Anonymous session created: KXkQn/K0GLGF2fXibq+ydR50qBNFAEj9lApqcoFKkJM=
2025/05/04 16:09:06 INFO Anonymous session created: 96IXin1xRasBmgmzmfQ5qAeHIHTf8G5g/DLFKPkiObE=
2025/05/04 16:09:06 INFO Anonymous session created: yORuw2suMVJCtOu7ctwTtQ+Yuc9KfiOxZFnCO17AMOQ=
2025/05/04 16:09:06 INFO CSRF-Token created: 3znobv1bV4TArb+HlZAnQud2oDr91djlmj1oowICPgw=
2025/05/04 16:09:06 INFO Anonymous session created: DHc4LdLcXut8tz34/orrAwqqIkvDdGHUJZHW8AotxD4=
2025/05/04 16:09:06 INFO [::1]:43956 200 GET / 367.317µs
2025/05/04 16:09:06 INFO CSRF-Token created: k9OMRAttUZAW3GID+8pTAtlvNg/rhg2tCtuAXRQsK8k=
2025/05/04 16:09:06 INFO CSRF-Token created: MMHaZEyGlT00wJm8QJ/zjbeYXe5dYeRlOHSkaFrLefY=
2025/05/04 16:09:06 INFO CSRF-Token created: MT/+XjYby0V6+Ogiu0oK1CVj97doM9NN6qQJ3UI0BEU=
2025/05/04 16:09:06 INFO [::1]:59616 200 GET / 243.011µs
2025/05/04 16:09:06 INFO [::1]:34368 200 GET / 297.074µs
2025/05/04 16:09:06 INFO [::1]:41530 200 GET / 631.609µs
2025/05/04 16:09:06 INFO CSRF-Token created: XLhEELc4GagnjsqM9dCnsGBYUJTTalfGQkyCnLAvQ8o=
2025/05/04 16:09:06 INFO Anonymous session created: b+t2fEsEzLlAAghoMa5ewjzEveTEIS+sxBMG6okX7ZA=
2025/05/04 16:09:06 INFO [::1]:45866 200 GET / 187.947µs
2025/05/04 16:09:06 INFO CSRF-Token created: 1VDgPWHIHVpHP7jAj4qGhJ7z/jzSzzF/1nSKKkAE/9o=
2025/05/04 16:09:06 INFO CSRF-Token created: +PX5YUQsBCxJ+ZJwysknZGsBEGowKm1u8MVq3Vqa6Us=
2025/05/04 16:09:06 INFO [::1]:45206 200 GET / 257.289µs
2025/05/04 16:09:06 INFO [::1]:58582 200 GET / 251.327µs
2025/05/04 16:09:06 INFO CSRF-Token created: Lbi+NGRvTGirVxRtrJtG9eIbJ2kau6g/lqYlWM+g7hg=
2025/05/04 16:09:06 INFO [::1]:54694 200 GET / 371.716µs
2025/05/04 16:09:06 INFO CSRF-Token created: HQj39ldO/6RZ1O5QXjxo9l5h4eKArXR5ItB2L8vOdvw=
2025/05/04 16:09:06 INFO [::1]:32870 200 GET / 342.581µs
2025/05/04 16:09:06 INFO CSRF-Token created: YX0jsNaJrkydun3SGCNCPfQv3kKamjihqUuYdvlUKVI=
2025/05/04 16:09:06 INFO [::1]:39118 200 GET / 382.175µs
2025/05/04 16:09:06 INFO Anonymous session created: Oz1R4FzF5KXvoh+UkLYKzo26g0TSRzQ8Qpr4S6GeZs0=
2025/05/04 16:09:06 INFO Anonymous session created: g89TfWKMRSB/KYc6hgbLzaNUDE7gVNu93y0hwQ9t0ng=
2025/05/04 16:09:06 INFO Anonymous session created: 1jX9ZQ3qF7S3XmE5qzzONuvZZW7S0QFmGKFG9wUsZys=
2025/05/04 16:09:06 INFO CSRF-Token created: zXrh7szBHAU5cay8Y5G+MDRlC0WDFEdkc252lPwgFFQ=
2025/05/04 16:09:06 INFO [::1]:32886 200 GET / 233.733µs
2025/05/04 16:09:06 INFO CSRF-Token created: 2PZFlciuS9IXsGHE/tCC4ln2WF7ioEYWYwAqVGi3MVE=
2025/05/04 16:09:06 INFO CSRF-Token created: 39QaZTEA2+VCSd2UlDZHU79PoIah0Ah6IgC5JIhDA7E=
2025/05/04 16:09:06 INFO [::1]:39122 200 GET /static/favicon.svg 259.503µs
2025/05/04 16:09:06 INFO [::1]:59620 200 GET / 247.07µs
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1027
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1026
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1025
2025/05/04 16:09:06 INFO Anonymous session created: FfjlcLEoLOpEigTj/DAkrROCKw3GmDO6iweXarlQWk4=
2025/05/04 16:09:06 INFO CSRF-Token created: w/vDIvYUaHW/MC44I+iosJEUbfutbcPDIHbvoZKjm8A=
2025/05/04 16:09:06 INFO [::1]:43966 200 GET /auth/signin 339.434µs
2025/05/04 16:09:06 INFO Anonymous session created: heG8kfN+gpvo86Kw7iqm7I+FPa9BEOJbfTXvGmwqnQM=
2025/05/04 16:09:06 INFO Anonymous session created: nesB1mHXOhBKfz2tDkMDplmQ7g2DCJAe8a6qk0KcaNU=
2025/05/04 16:09:06 INFO Anonymous session created: mxAV7nBg8MYYSv9rkw7+sf/dvO2L4knqZojnRv47YOI=
2025/05/04 16:09:06 INFO Token 'invalid-csrf-token' not found
2025/05/04 16:09:06 INFO CSRF-Token not correct
2025/05/04 16:09:06 INFO [::1]:34374 400 POST /api/auth/signin 143.582µs
2025/05/04 16:09:06 INFO Anonymous session created: 0v14+y4mILt1rhGCX/ZP675Wub7XmCsxn0xQz7A4guI=
2025/05/04 16:09:06 INFO CSRF-Token created: Ofm7h/1cpZXSCiHmUVMkN7RGS4qT2OO9VDmNfW3cfg4=
2025/05/04 16:09:06 INFO [::1]:54706 200 GET /auth/signin 151.548µs
2025/05/04 16:09:06 INFO CSRF-Token created: OfaE1yY75jbkHj4SQxYdpxYQuXdxG3xyaMhPH8JwIZk=
2025/05/04 16:09:06 INFO [::1]:41540 200 GET /auth/signin 163.099µs
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1034
2025/05/04 16:09:06 INFO CSRF-Token created: ihlV3z7w1lTnFTuY2oZbPPZdw/XxRq1erl6wdfoJ4os=
2025/05/04 16:09:06 INFO [::1]:45874 200 GET /auth/signin 442.52µs
2025/05/04 16:09:06 INFO Anonymous session created: C3zFi5om0T8kdqGPRhWr54XoLvX+AUzFKPM+7rX4ciQ=
2025/05/04 16:09:06 INFO CSRF-Token created: MnHKHrS5TqoEd0lWFLTJBRjXfLhagXUum+6K1RIj+0k=
2025/05/04 16:09:06 INFO [::1]:58598 200 GET /auth/signin 142.921µs
2025/05/04 16:09:06 INFO CSRF-Token created: swEu7fAiz6NC9x6bRoEBg7Z2uGKpmsMwx/mn286wxrE=
2025/05/04 16:09:06 INFO [::1]:45208 303 GET /auth/signin 342.33µs
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1029
2025/05/04 16:09:06 INFO [::1]:43966 401 POST /api/auth/signin 250.291568ms
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1032
2025/05/04 16:09:06 INFO [::1]:41540 303 POST /api/auth/signin 251.921223ms
2025/05/04 16:09:06 INFO [::1]:54706 401 POST /api/auth/signin 251.965506ms
2025/05/04 16:09:06 INFO [::1]:58598 200 POST /api/auth/signin 250.279857ms
2025/05/04 16:09:06 INFO [::1]:45874 200 POST /api/auth/signin 251.907547ms
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1031
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1028
2025/05/04 16:09:06 INFO Gracefully stopped http server on :1033
2025/05/04 16:09:06 INFO Anonymous session created: +LBaMyqxxiZ40ESawYlRHgrdsnODTBckcx1OeSAWWwo=
2025/05/04 16:09:06 INFO CSRF-Token created: uNQ2j9KYIdO3mWCTMqvHS713erezZvpjuQwPQ4YKfso=
2025/05/04 16:09:06 INFO [::1]:45874 200 GET /auth/signin 395.09µs
2025/05/04 16:09:06 INFO [::1]:45874 401 POST /api/auth/signin 250.272021ms
2025/05/04 16:09:06 INFO Anonymous session created: qeonVn4lJ5XrSzP738w10/EeiKmla7tN7I4QQxUM5Uc=
2025/05/04 16:09:06 INFO CSRF-Token created: YlroafoG+2gUjoxq0xlxF795ZFjlgYePQauB2ZvpZIk=
2025/05/04 16:09:06 INFO [::1]:45874 200 GET /auth/signin 237.571µs
2025/05/04 16:09:07 INFO [::1]:45874 401 POST /api/auth/signin 250.91927ms
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1030
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1035"
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1038"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1039"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1036"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1037"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server on ":1039"
2025/05/04 16:09:07 INFO Starting server on ":1035"
2025/05/04 16:09:07 INFO Starting server on ":1036"
2025/05/04 16:09:07 INFO Starting server on ":1038"
2025/05/04 16:09:07 INFO Starting server on ":1037"
2025/05/04 16:09:07 INFO Anonymous session created: xhq2IBLbAz+f/CY7UOIMov+zJb3Co14in/B/kB8bV7E=
2025/05/04 16:09:07 INFO Anonymous session created: 71c61oHlq2zOWvnUy1bf4NdAJjXDyKrD8QV2xaP6Z7Q=
2025/05/04 16:09:07 INFO Anonymous session created: FJAnOEJqCvcxvXZ+p+0x1QefhsKiOycb1m6LwYK4qxs=
2025/05/04 16:09:07 INFO Anonymous session created: PdNi9YruTOFlvQl4jWxyUSLu/QC+hubl6ercCqGDBAc=
2025/05/04 16:09:07 INFO CSRF-Token created: 3uqSRAb6IGH+yiUeP+lJgz3Zud1ixBB4IA8N47b8dhI=
2025/05/04 16:09:07 INFO Anonymous session created: Ebe1moB4HEBtxEOlTI3qKEIUrtOy8lV8pHiPpix1vRM=
2025/05/04 16:09:07 INFO [::1]:34596 200 GET / 197.385µs
2025/05/04 16:09:07 INFO CSRF-Token created: nkMHsmtBWVoBKfwfAkkl5bqR8x7jMpnmiSJNY4QUQRo=
2025/05/04 16:09:07 INFO CSRF-Token created: RM0p9rPPjLo+80oS90RLGNU9PrmwX3A6zX29vmCPts8=
2025/05/04 16:09:07 INFO CSRF-Token created: ck31atN4NsBZYZP2r6zZUFfNdmiS9HGTZv/W+HfzR0I=
2025/05/04 16:09:07 INFO [::1]:47544 200 GET / 267.929µs
2025/05/04 16:09:07 INFO [::1]:54172 200 GET / 344.093µs
2025/05/04 16:09:07 INFO [::1]:37332 200 GET / 331.66µs
2025/05/04 16:09:07 INFO CSRF-Token created: PeH4DG/WYHSggyCRrEa0h0OUcpvNBT6oslEcHYYB6rg=
2025/05/04 16:09:07 INFO [::1]:35134 200 GET / 345.566µs
2025/05/04 16:09:07 INFO Anonymous session created: RUrlOobLzrlkVlhikL2bxNzICH+nDGUlmKrapuDZtCU=
2025/05/04 16:09:07 INFO Anonymous session created: 1EyxHttARbGmQ4mKNrhnWhlZPoGasRHZxdsA9pGFei4=
2025/05/04 16:09:07 INFO Anonymous session created: RX/UuisLp1bsW7BdsonpHEs1H2dNx7VHqThIP9IWS7U=
2025/05/04 16:09:07 INFO CSRF-Token created: VZCcq+YZ1HR8mznXqgM5hLr7kNSdkXh7cBpnncIhO/0=
2025/05/04 16:09:07 INFO [::1]:34610 200 GET /auth/signup 304.768µs
2025/05/04 16:09:07 INFO Token 'invalid-csrf-token' not found
2025/05/04 16:09:07 INFO CSRF-Token not correct
2025/05/04 16:09:07 INFO [::1]:37344 400 POST /api/auth/signin 131.931µs
2025/05/04 16:09:07 INFO CSRF-Token created: J2/qBrloOVEKXYutfuO2GqlK+D7GDOt+TbMl0NAW2O0=
2025/05/04 16:09:07 INFO [::1]:35144 200 GET /auth/signup 184.079µs
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1038
2025/05/04 16:09:07 INFO Signing up mail@mail.de
2025/05/04 16:09:07 INFO Signing up mail@mail.de
2025/05/04 16:09:07 INFO Sending verification email to mail@mail.de
2025/05/04 16:09:07 INFO Anonymous session created: n0QDZgtZbdxuLLe9b41sr++cp+Wm5O6iA8W6qzNe0iE=
2025/05/04 16:09:07 INFO CSRF-Token created: vpoo1zdtO294QjQg/8RAu74OTTgOMz6hDq0XMvPlhTU=
2025/05/04 16:09:07 INFO CSRF-Token created: 59jPp8DxfM4afaLMVVlXGTSydDm79ehsBJfrpKOA1iY=
2025/05/04 16:09:07 INFO [::1]:54178 303 GET /auth/signin 140.818µs
2025/05/04 16:09:07 INFO [::1]:47554 200 GET /auth/signup 167.969µs
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1035
2025/05/04 16:09:07 INFO Signing up mail@mail.de
2025/05/04 16:09:07 INFO [::1]:34610 200 POST /api/auth/signup 250.628749ms
2025/05/04 16:09:07 INFO [::1]:35144 400 POST /api/auth/signup 250.680327ms
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1036
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1039
2025/05/04 16:09:07 INFO [::1]:47554 200 POST /api/auth/signup 250.575518ms
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1037
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1042"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1040"
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1041"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server on ":1042"
2025/05/04 16:09:07 INFO Starting server on ":1040"
2025/05/04 16:09:07 INFO Starting server on ":1041"
2025/05/04 16:09:07 INFO Anonymous session created: jo6Z7JapKy5+Mp4L6uOK/FM7gTZwREXXvgMmJsSofJs=
2025/05/04 16:09:07 INFO Anonymous session created: hCDPoztBLkErAxojn3VzQtHPjyEcgnpgvfZFESIHptI=
2025/05/04 16:09:07 INFO Anonymous session created: U2pccLWqFqRHFPcuLd7uhUAvNQJ846mdDAvDsqA9Ahk=
2025/05/04 16:09:07 INFO CSRF-Token created: IQMsxPprvraH+EKAcCJp7bukSTQ2N7po4gtCczKvtYg=
2025/05/04 16:09:07 INFO CSRF-Token created: vvbuIZBXqU1qT8r1C/nFqwTiMSRd26vlaMzOd2S49Zs=
2025/05/04 16:09:07 INFO [::1]:48190 200 GET / 391.504µs
2025/05/04 16:09:07 INFO [::1]:55320 200 GET / 325.417µs
2025/05/04 16:09:07 INFO CSRF-Token created: zpH6K3bG3YfLFT4QiLrH3shUEXYaotdyeJ+wBo2PJ+0=
2025/05/04 16:09:07 INFO [::1]:45470 200 GET / 486.995µs
2025/05/04 16:09:07 INFO Anonymous session created: IxR1A3yIeNZVqvUpZDpX0wVh5zXOGe20xfzDDggECIY=
2025/05/04 16:09:07 INFO Anonymous session created: u+aqhieHGjYRdG3X8FuG9ijqZIyp53E7uchETvPHeqU=
2025/05/04 16:09:07 INFO CSRF-Token created: WG9JuKkMyqod4Yj39Tw8F3+8i0c7pnGfJRBM7ppTFFc=
2025/05/04 16:09:07 INFO [::1]:48200 200 GET /auth/verify-email 149.474µs
2025/05/04 16:09:07 ERROR Could not get token: sql: Scan error on column index 1, name "session_id": converting NULL to string is unsupported
2025/05/04 16:09:07 INFO Anonymous session created: QWcreHbpTncV4QAPi5uz9/3QwcolCREP5gaMcsH/97A=
2025/05/04 16:09:07 INFO Token 'invalid-token' not found
2025/05/04 16:09:07 INFO CSRF-Token created: YNISy37y2IHKyVR70PGM2W3YgkuS6vdEOgsXMdDmFVc=
2025/05/04 16:09:07 INFO [::1]:55322 400 GET /auth/verify-email 111.241µs
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1041
2025/05/04 16:09:07 INFO CSRF-Token created: 6chtzYbxsJA5B5F0UiqFPObZcHRv1WLUqiHzv8uCggQ=
2025/05/04 16:09:07 INFO [::1]:45476 400 GET /auth/verify-email 275.704µs
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1040
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1043"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Gracefully stopped http server on :1042
2025/05/04 16:09:07 INFO Starting server...
2025/05/04 16:09:07 INFO BASE_URL is "http://localhost:1044"
2025/05/04 16:09:07 INFO ENVIRONMENT is "test"
2025/05/04 16:09:07 INFO Starting server on ":1043"
2025/05/04 16:09:07 INFO Starting server on ":1044"
2025/05/04 16:09:08 INFO Anonymous session created: 854gAsAl3mE0zV1Q1svNDSFWQ6nluq3f55epIfLMhPo=
2025/05/04 16:09:08 INFO Anonymous session created: mhaSgZjdqkhC6Sa3LuEyKchemWZXYcQBqh86Zgfd3O4=
2025/05/04 16:09:08 INFO CSRF-Token created: xLIJE9FfBg3Zgf1CUUsrX12r/CWfOc+5hvXy8ANi3Q8=
2025/05/04 16:09:08 INFO [::1]:60358 200 GET / 316.1µs
2025/05/04 16:09:08 INFO CSRF-Token created: 32N6JsF7BvBZ+WoDvyS7BzkKBqZOgjOR+u9zK581yKs=
2025/05/04 16:09:08 INFO [::1]:36078 200 GET / 417.282µs
2025/05/04 16:09:08 INFO Anonymous session created: EiHwa5bmN142PTYFJQdsyAfPzd7V26SgNRjLO2qGRls=
2025/05/04 16:09:08 INFO Token 'invalid-csrf-token' not found
2025/05/04 16:09:08 INFO CSRF-Token not correct
2025/05/04 16:09:08 INFO [::1]:36086 400 POST /api/auth/sign-out 163.04µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1043
2025/05/04 16:09:08 INFO CSRF-Token created: Nfk4NBMIA8O5lG5Agy+6SzwPB4Ui8/NcTokJx+ugcF8=
2025/05/04 16:09:08 INFO [::1]:60362 200 GET / 259.192µs
2025/05/04 16:09:08 INFO [::1]:60372 303 POST /api/auth/signout 69.102µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1044
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1045"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1048"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1047"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1049"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1046"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server on ":1045"
2025/05/04 16:09:08 INFO Starting server on ":1048"
2025/05/04 16:09:08 INFO Starting server on ":1046"
2025/05/04 16:09:08 INFO Starting server on ":1047"
2025/05/04 16:09:08 INFO Starting server on ":1049"
2025/05/04 16:09:08 INFO Anonymous session created: r1l6P81B5bjvAtYgEs+kpDCss1jzJSDOC6D0gPcRC1Q=
2025/05/04 16:09:08 INFO Anonymous session created: T8aTRMQ+OAHTI0ts0FbGMHdACkPOdZl7+ELtTq3tz6g=
2025/05/04 16:09:08 INFO Anonymous session created: 0Yd55Rluy8MSjEPfWd3a/UQQ7xsxxV3HZrs1OZE5i8g=
2025/05/04 16:09:08 INFO CSRF-Token created: W/1hRY9hi0R7kMYQXLSCi84JrPVt5a1kX3LBrITCQds=
2025/05/04 16:09:08 INFO [::1]:34846 200 GET / 194.78µs
2025/05/04 16:09:08 INFO Anonymous session created: XT52HDsdTpCuo1f6ouCxb0rathiNkURS2gnsqE18DRk=
2025/05/04 16:09:08 INFO Anonymous session created: t7RB/YcpJpwiXoVVycJfzP2BhQ55Qc0YG6zgpFQZP8E=
2025/05/04 16:09:08 INFO CSRF-Token created: 4grA+kACaEyZR98Il3ElKJBimZp19xn1A27YKChpIhE=
2025/05/04 16:09:08 INFO CSRF-Token created: pb7IOSCtrmj+7M2n6bBUSVrKuFtElh4Oc7tSRBfEHYs=
2025/05/04 16:09:08 INFO [::1]:60418 200 GET / 305.169µs
2025/05/04 16:09:08 INFO [::1]:53504 200 GET / 344.705µs
2025/05/04 16:09:08 INFO CSRF-Token created: G+TunCuuTHlb0S7VwMHiXPIikwG8NDYUTt/ZoocMPII=
2025/05/04 16:09:08 INFO [::1]:56720 200 GET / 809.687µs
2025/05/04 16:09:08 INFO CSRF-Token created: Xcaewg4EtCBsDX++vDCRBye6bNBwCxkdF7nFNP88h6U=
2025/05/04 16:09:08 INFO [::1]:57866 200 GET / 1.212432ms
2025/05/04 16:09:08 INFO Anonymous session created: hZEtjYJgNMWXtU5fIBk2UHyL57zMOtHDp7znqK6LW4o=
2025/05/04 16:09:08 INFO CSRF-Token not correct
2025/05/04 16:09:08 INFO Anonymous session created: VWKij/LgmDomxPOq7RwXfadZP10CY6+Eq5Q9MQYgV7U=
2025/05/04 16:09:08 INFO [::1]:56730 400 POST /api/auth/delete-account 113.726µs
2025/05/04 16:09:08 INFO CSRF-Token created: 3fJ2mo7ZLuRM74AdLOO3COIhtJwAqFvuSso4B0cPaNA=
2025/05/04 16:09:08 INFO [::1]:60434 303 GET /auth/delete-account 156.708µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1045
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1048
2025/05/04 16:09:08 INFO Token 'wrong-csrf-token' not found
2025/05/04 16:09:08 INFO CSRF-Token not correct
2025/05/04 16:09:08 INFO [::1]:34848 400 POST /api/auth/delete-account 116.441µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1047
2025/05/04 16:09:08 INFO CSRF-Token created: CXDb/h5hm5uSxkbNAGKCHEmmUOVO4AMOUwsjApGZgQo=
2025/05/04 16:09:08 INFO [::1]:53512 200 GET /auth/delete-account 180.412µs
2025/05/04 16:09:08 INFO CSRF-Token created: kTJK4VhAk17nGB7nN6RZ+z5qJ1hNhDZ0y6nsKRk1zcA=
2025/05/04 16:09:08 INFO [::1]:57868 200 GET /auth/delete-account 300.711µs
2025/05/04 16:09:08 INFO [::1]:57868 400 POST /api/auth/delete-account 31.520081ms
2025/05/04 16:09:08 ERROR Could not delete workouts: no such table: workout
2025/05/04 16:09:08 INFO [::1]:53512 500 POST /api/auth/delete-account 48.218363ms
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1049
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1046
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1053"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1050"
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1052"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1051"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1055"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1054"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server on ":1053"
2025/05/04 16:09:08 INFO Starting server on ":1055"
2025/05/04 16:09:08 INFO Starting server on ":1054"
2025/05/04 16:09:08 INFO Starting server on ":1052"
2025/05/04 16:09:08 INFO Starting server on ":1051"
2025/05/04 16:09:08 INFO Starting server on ":1050"
2025/05/04 16:09:08 INFO Anonymous session created: xd1mXTWn5ZZAYnTiIszwXAeUFy/fL99MeP7LwqIgerk=
2025/05/04 16:09:08 INFO Anonymous session created: ENqUYxguF8j9Q7GLT3oWY/++eb9CUDILQ8xVSHPwVu8=
2025/05/04 16:09:08 INFO Anonymous session created: t6vUSQA4ec5tXpIiIegzATp8DQY9toEF/KxsM57uX1s=
2025/05/04 16:09:08 INFO Anonymous session created: 5RBBXq3M1PAtwUWqnQ7Ll5p/1gZsv00F5NKCgjuUTwU=
2025/05/04 16:09:08 INFO Anonymous session created: PzpOOdkCCxy7rIdhrOMgWid9r49r3Dz4gFifT5g41fg=
2025/05/04 16:09:08 INFO Anonymous session created: PY8YUKM82L/tAUjhrn53fLqD4qzFRIAEmNiHQ4x55Xk=
2025/05/04 16:09:08 INFO CSRF-Token created: yqhCY3jkfFD2/QKnRFokA+PFpmzyGKYIPwj/egC4CLE=
2025/05/04 16:09:08 INFO [::1]:43092 200 GET / 254.533µs
2025/05/04 16:09:08 INFO CSRF-Token created: 77cVIkgGijSk5xB+h6wIj53NmopBuOR0rWdmF7HsI5E=
2025/05/04 16:09:08 INFO [::1]:37470 200 GET / 279.751µs
2025/05/04 16:09:08 INFO CSRF-Token created: HC5uypZg/hQkkHVjxEVtk2KKLNgeJngfpZcjHBl2tVI=
2025/05/04 16:09:08 INFO [::1]:59020 200 GET / 276.956µs
2025/05/04 16:09:08 INFO CSRF-Token created: 4IheFhABdSeQmeDGZkrDb1F7rmU0/I/ff6Wz6rOBdB0=
2025/05/04 16:09:08 INFO CSRF-Token created: 2b/SyQIGJWKhNZAOEs58y3NjHeNWmPX+wbmYezcq7Lo=
2025/05/04 16:09:08 INFO [::1]:53478 200 GET / 358.06µs
2025/05/04 16:09:08 INFO CSRF-Token created: OGRXde04GcOpUR58XgH7NTvvjtNXYKZiYtEFpYlpCPs=
2025/05/04 16:09:08 INFO [::1]:46564 200 GET / 364.843µs
2025/05/04 16:09:08 INFO [::1]:34404 200 GET / 299.659µs
2025/05/04 16:09:08 INFO Anonymous session created: HxvqtFVnh4XNpPNSlixBKM9CUAsZKQkp8QwKYJAsGxA=
2025/05/04 16:09:08 INFO Anonymous session created: 4PswFHiRAHiV4zhdz1RgAZknQFbrSIUSrO9xHxfr1fM=
2025/05/04 16:09:08 INFO CSRF-Token created: e12W/Efzfg0+QC6Qq1KDitQfLNbBu3zTeK/Bw4EzlWE=
2025/05/04 16:09:08 INFO [::1]:34408 303 GET /auth/change-password 382.165µs
2025/05/04 16:09:08 INFO CSRF-Token created: 6KZmcA1f/rjcKkND/PtP94+U5sXjKSTaWD1sorBP8NI=
2025/05/04 16:09:08 INFO [::1]:37478 200 GET /auth/signin 234.806µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1050
2025/05/04 16:09:08 INFO [::1]:37478 401 POST /api/auth/change-password 94.389µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1052
2025/05/04 16:09:08 INFO CSRF-Token created: 6cmY2Ik6wo7QZEtGXhg9wK5O2OEDTY8BZAA2ZFtWu54=
2025/05/04 16:09:08 INFO [::1]:53486 200 GET /auth/change-password 228.724µs
2025/05/04 16:09:08 INFO CSRF-Token created: O1/1VPy9ED1Krk9BiZVZqBa7MSvdZyS+LmSJq15mjVg=
2025/05/04 16:09:08 INFO [::1]:46570 200 GET /auth/change-password 117.974µs
2025/05/04 16:09:08 INFO Token 'invalid-csrf-token' not found
2025/05/04 16:09:08 INFO CSRF-Token not correct
2025/05/04 16:09:08 INFO [::1]:59030 400 POST /api/auth/change-password 156.557µs
2025/05/04 16:09:08 INFO [::1]:46570 400 POST /api/auth/change-password 46.218µs
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1053
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1054
2025/05/04 16:09:08 INFO CSRF-Token created: A/i/s6H3Son87BW4kq1YaXGhICDGwHxFrwgWtB7aMkM=
2025/05/04 16:09:08 INFO [::1]:43104 200 GET /auth/change-password 195.411µs
2025/05/04 16:09:08 INFO [::1]:53486 400 POST /api/auth/change-password 47.082787ms
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1051
2025/05/04 16:09:08 INFO [::1]:43104 200 POST /api/auth/change-password 87.731017ms
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1057"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1056"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Gracefully stopped http server on :1055
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO Starting server...
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1058"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO BASE_URL is "http://localhost:1059"
2025/05/04 16:09:08 INFO ENVIRONMENT is "test"
2025/05/04 16:09:08 INFO Starting server on ":1057"
2025/05/04 16:09:08 INFO Starting server on ":1059"
2025/05/04 16:09:08 INFO Starting server on ":1056"
2025/05/04 16:09:08 INFO Starting server on ":1058"
2025/05/04 16:09:09 INFO Anonymous session created: Lfa65ZLijd9A8ybwL7mOa+dUNnzmehnP29h7EkiMzg8=
2025/05/04 16:09:09 INFO Anonymous session created: hPcKg1TM5o62yX3heszCWqBFUFRN3NPGq6+t+PByiH0=
2025/05/04 16:09:09 INFO Anonymous session created: r+Js1J/2Qi1xGL2R20g+q0KDbhkQGfpvTR41Q+ubCwo=
2025/05/04 16:09:09 INFO Anonymous session created: K6KSVynVZ2vroau1Hmq+f+upnRrtmdlak69iKe7gJnk=
2025/05/04 16:09:09 INFO CSRF-Token created: /BQo8DCsout5rcBpxHXfv4JtJv8tccA0vjTkdrhAW9s=
2025/05/04 16:09:09 INFO CSRF-Token created: 9KZlDLpU5EFWjoU0wWaR+y1kTZFRlUECmSAIqvyicnE=
2025/05/04 16:09:09 INFO [::1]:44734 200 GET / 291.343µs
2025/05/04 16:09:09 INFO [::1]:52430 200 GET / 324.697µs
2025/05/04 16:09:09 INFO CSRF-Token created: ipTmbEPHNtUunc5peCg02yXJWDU6C4cIwaoKTykpLg4=
2025/05/04 16:09:09 INFO [::1]:59864 200 GET / 237.982µs
2025/05/04 16:09:09 INFO CSRF-Token created: QuPLy+EgiucAgHXv/hsQdrJYe7qA6z4Ul4zaQRQ+KFM=
2025/05/04 16:09:09 INFO [::1]:51392 200 GET / 291.033µs
2025/05/04 16:09:09 INFO Anonymous session created: 9HjVjEW2GD1Pb5vNtA7cCn//W7hZdP1JRTMq0rb2qhA=
2025/05/04 16:09:09 INFO CSRF-Token created: QN55nsXVkitPu2PQFBROtdjSRrpjQXRMlvyUktwKbeg=
2025/05/04 16:09:09 INFO [::1]:51398 200 GET /auth/forgot-password 686.202µs
2025/05/04 16:09:09 INFO Anonymous session created: W/4UsFOsg0HQgVUzBnClrpZ9hagqGujeohVDF9CptL4=
2025/05/04 16:09:09 INFO CSRF-Token created: o46grNIHFVAhOz47erqEMWMQ9Vx5zEZTLQQJT/L5bxA=
2025/05/04 16:09:09 INFO CSRF-Token created: ykibTAavOkHX+fegECTcu1NcEa3bdouuPuCy1Zk1eY4=
2025/05/04 16:09:09 INFO [::1]:59878 200 GET /auth/forgot-password 258.801µs
2025/05/04 16:09:09 INFO [::1]:44736 303 GET /auth/forgot-password 113.645µs
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1057
2025/05/04 16:09:09 INFO Anonymous session created: 5gezmcCiFCeF4ldmHYuvTsdF3/Yhoho2O+siwEBhx9E=
2025/05/04 16:09:09 INFO Token 'invalid-csrf-token' not found
2025/05/04 16:09:09 INFO CSRF-Token not correct
2025/05/04 16:09:09 INFO [::1]:59884 400 POST /api/auth/forgot-password 62.308µs
2025/05/04 16:09:09 INFO Anonymous session created: sB7LgNGEbOF5DSSMzwmSshATc51GFcbDS1Rlv7fZJ6s=
2025/05/04 16:09:09 INFO CSRF-Token created: W4MNydFZuSdvtZIMMvUqAHUJ0WhBr23Dcoz90LKCID4=
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1058
2025/05/04 16:09:09 INFO [::1]:52442 200 GET /auth/forgot-password 179.26µs
2025/05/04 16:09:09 INFO [::1]:51398 200 POST /api/auth/forgot-password 251.419781ms
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1059
2025/05/04 16:09:09 INFO [::1]:52442 200 POST /api/auth/forgot-password 250.816416ms
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1056
2025/05/04 16:09:09 INFO Starting server...
2025/05/04 16:09:09 INFO BASE_URL is "http://localhost:1060"
2025/05/04 16:09:09 INFO ENVIRONMENT is "test"
2025/05/04 16:09:09 INFO Starting server...
2025/05/04 16:09:09 INFO BASE_URL is "http://localhost:1061"
2025/05/04 16:09:09 INFO ENVIRONMENT is "test"
2025/05/04 16:09:09 INFO Starting server...
2025/05/04 16:09:09 INFO BASE_URL is "http://localhost:1062"
2025/05/04 16:09:09 INFO ENVIRONMENT is "test"
2025/05/04 16:09:09 INFO Starting server...
2025/05/04 16:09:09 INFO BASE_URL is "http://localhost:1063"
2025/05/04 16:09:09 INFO ENVIRONMENT is "test"
2025/05/04 16:09:09 INFO Starting server on ":1063"
2025/05/04 16:09:09 INFO Starting server on ":1062"
2025/05/04 16:09:09 INFO Starting server on ":1060"
2025/05/04 16:09:09 INFO Starting server on ":1061"
2025/05/04 16:09:09 INFO Anonymous session created: oCgpL+oZhB3Qxun4suBEgG3mPIgT5hmayl60V/1RmwQ=
2025/05/04 16:09:09 INFO Anonymous session created: MJPq32rxlQv17sLxU5SpdSB/KwbJsZ3U4w3cvsN3uC0=
2025/05/04 16:09:09 INFO Anonymous session created: CUlQ+bggY/0A/rDAR3sptz+J3n2Bdl9Fzke2ZFj3PRs=
2025/05/04 16:09:09 INFO CSRF-Token created: YZJRCSOkI6dGoiNbXmCAg7MYGoFUOA7Lr59XcLo3YF8=
2025/05/04 16:09:09 INFO Anonymous session created: 96U59EFvYbOqmF26RwH2Hqu3/FFTNss8S43AM/K388Q=
2025/05/04 16:09:09 INFO [::1]:35294 200 GET / 196.252µs
2025/05/04 16:09:09 INFO CSRF-Token created: vK29fXyz7zbk8RZaAyVOHaKkvdXp51F15wDrMK/ZePI=
2025/05/04 16:09:09 INFO CSRF-Token created: hGjTpCf8rPPFQgaifNbuevCVNKB5rCC+gGzRyTdqVNw=
2025/05/04 16:09:09 INFO [::1]:55424 200 GET / 284.911µs
2025/05/04 16:09:09 INFO [::1]:44574 200 GET / 311.472µs
2025/05/04 16:09:09 INFO CSRF-Token created: 1zsSCsDcU2ZC2NEjulCbHJ6ZOI8VlW/oboKgopYwYdk=
2025/05/04 16:09:09 INFO [::1]:50910 200 GET / 288.638µs
2025/05/04 16:09:09 INFO Anonymous session created: ts1l2m7Elvo8//oCix6Lr4K0OcIICSsLd7Lj588JkqM=
2025/05/04 16:09:09 INFO CSRF-Token created: bSGjupmgc72ZgO9/prg2tGlSVG7q/B5cDZILsHiFmWo=
2025/05/04 16:09:09 INFO [::1]:55428 200 GET /auth/forgot-password 207.965µs
2025/05/04 16:09:09 INFO Anonymous session created: jP7c4Q6ph70c4ZeOweRvIfBfYHE1gqgJsUJuCbUP8J4=
2025/05/04 16:09:09 INFO Anonymous session created: 8dSyXuUzq9vw40Gl5hNiYSmJQ6i/KCTefPiY4+9u1Wc=
2025/05/04 16:09:09 INFO CSRF-Token created: +rZh42okgPd3/fF/xl4NFwvwX3MiM7zjlcGG3zM5XFM=
2025/05/04 16:09:09 INFO [::1]:44576 200 GET /auth/forgot-password 114.216µs
2025/05/04 16:09:09 INFO CSRF-Token created: WKPcqDP2N7aXFj387PBryeZWCPIWg6BVP8mvLsYiCn4=
2025/05/04 16:09:09 INFO [::1]:50926 200 GET /auth/forgot-password 251.918µs
2025/05/04 16:09:09 INFO [::1]:55428 400 POST /api/auth/forgot-password-actual 66.767µs
2025/05/04 16:09:09 INFO Token 'invalidToken' not found
2025/05/04 16:09:09 INFO [::1]:44576 400 POST /api/auth/forgot-password-actual 65.374µs
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1062
2025/05/04 16:09:09 INFO [::1]:50926 400 POST /api/auth/forgot-password-actual 43.933µs
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1060
2025/05/04 16:09:09 INFO Gracefully stopped http server on :1061
2025/05/04 16:09:09 INFO Anonymous session created: tdGFl8JZXU3Wc1np+/XpX0/QoAe3kiLvgQm8HjrYSig=
2025/05/04 16:09:09 INFO CSRF-Token created: aVH669YBJv1OWpp34b+Qjuz+M5HsPODfHTFov8s12nw=
2025/05/04 16:09:09 INFO [::1]:35308 200 GET /auth/forgot-password 240.858µs
2025/05/04 16:09:10 INFO [::1]:35308 200 POST /api/auth/forgot-password 250.688653ms
2025/05/04 16:09:10 INFO [::1]:35308 200 POST /api/auth/forgot-password-actual 54.280642ms
2025/05/04 16:09:10 INFO Gracefully stopped http server on :1063
2025/05/04 16:09:10 INFO Starting server...
2025/05/04 16:09:10 INFO BASE_URL is "http://localhost:1066"
2025/05/04 16:09:10 INFO ENVIRONMENT is "test"
2025/05/04 16:09:10 INFO Starting server...
2025/05/04 16:09:10 INFO BASE_URL is "http://localhost:1065"
2025/05/04 16:09:10 INFO ENVIRONMENT is "test"
2025/05/04 16:09:10 INFO Starting server...
2025/05/04 16:09:10 INFO BASE_URL is "http://localhost:1064"
2025/05/04 16:09:10 INFO ENVIRONMENT is "test"
2025/05/04 16:09:10 INFO Starting server on ":1064"
2025/05/04 16:09:10 INFO Starting server on ":1065"
2025/05/04 16:09:10 INFO Starting server on ":1066"
2025/05/04 16:09:10 INFO Anonymous session created: pNnVTAfsxKbEKVbL964pbEiz0Yo97GiKfTzgMmeDXag=
2025/05/04 16:09:10 INFO Anonymous session created: XHGSC8/S+wpUU1+y69UJ6C6giGlJI/c+rjrCACAXpUQ=
2025/05/04 16:09:10 INFO Anonymous session created: i6xM86SMdzJbQ2iwhFxDJUoE1huvwRUXGgR8Grz2NLA=
2025/05/04 16:09:10 INFO CSRF-Token created: LxM0GzekPJwHiFbfIhvErKx+8ifFF1mzilPRCnWRA2M=
2025/05/04 16:09:10 INFO CSRF-Token created: ad1UAy/BwmOoVfaERQy1r41ZQ/MQVrvrmuCepW8B2SA=
2025/05/04 16:09:10 INFO [::1]:39678 200 GET / 453.261µs
2025/05/04 16:09:10 INFO [::1]:53820 200 GET / 459.462µs
2025/05/04 16:09:10 INFO CSRF-Token created: aPbPi98kbLcCPMhIkKpEdjgtfdFu7nXMsa7ILjbCE7M=
2025/05/04 16:09:10 INFO [::1]:56498 200 GET / 556.106µs
2025/05/04 16:09:10 INFO Anonymous session created: o4vsnS+KCdznIZ8m/RnSKUcX0SCrYGdiW6M/qACB+MI=
2025/05/04 16:09:10 INFO Anonymous session created: ge8Q/yJxdp1EHbCMn//9MM4DbUjnp/Wf+/6uOk5e0WM=
2025/05/04 16:09:10 INFO CSRF-Token created: tdfGaFG7xiDV9qUY/8oxdmJ+ihDVT5sgeIWApwHTzlw=
2025/05/04 16:09:10 INFO [::1]:39684 200 GET / 97.635µs
2025/05/04 16:09:10 INFO CSRF-Token created: d91wOB18AS3RL8mj7nGwgLaGX/a6muRyd5HPr79SC7Y=
2025/05/04 16:09:10 INFO [::1]:56514 200 GET / 160.425µs
2025/05/04 16:09:10 INFO Anonymous session created: s1+XE+fFIBra30AMXxWn9QLyeeVTF2VNXjFfVmmLVtk=
2025/05/04 16:09:10 INFO CSRF-Token created: jQRbdkV6aol/nsuC8exGnkqFrVQZaM4DfkaezfuHQr4=
2025/05/04 16:09:10 INFO [::1]:53828 404 GET /workout 129.997µs
2025/05/04 16:09:10 INFO Gracefully stopped http server on :1066
2025/05/04 16:09:10 INFO Gracefully stopped http server on :1064
--- FAIL: TestIntegrationAuth (4.39s)
--- FAIL: TestIntegrationAuth/DeleteAccount (0.00s)
--- FAIL: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data (0.34s)
main_test.go:919:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:919
Error: Not equal:
expected: 200
actual : 500
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
main_test.go:924:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:924
Error: Not equal:
expected: 0
actual : 1
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
main_test.go:927:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:927
Error: Not equal:
expected: 0
actual : 1
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
main_test.go:930:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:930
Error: Not equal:
expected: 0
actual : 1
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
main_test.go:932:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:932
Error: Expected nil, but got: sqlite3.Error{Code:1, ExtendedCode:1, SystemErrno:0x0, err:"no such table: workout"}
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
main_test.go:933:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:933
Error: Not equal:
expected: 0
actual : 1
Test: TestIntegrationAuth/DeleteAccount/should_delete_all_user_related_data
--- FAIL: TestIntegrationAuth/Session (0.00s)
--- FAIL: TestIntegrationAuth/Session/should_not_have_access_to_user_information_with_outdated_session (0.25s)
main_test.go:1611:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:1611
Error: Not equal:
expected: 303
actual : 404
Test: TestIntegrationAuth/Session/should_not_have_access_to_user_information_with_outdated_session
main_test.go:1612:
Error Trace: /home/tiwun/source/spend-sparrow/main_test.go:1612
Error: Not equal:
expected: "/auth/signin"
actual : ""
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-/auth/signin
+
Test: TestIntegrationAuth/Session/should_not_have_access_to_user_information_with_outdated_session
FAIL
2025/05/04 16:09:10 INFO Gracefully stopped http server on :1065
FAIL spend-sparrow 4.413s
ok spend-sparrow/db (cached)
? spend-sparrow/handler [no test files]
? spend-sparrow/handler/middleware [no test files]
? spend-sparrow/log [no test files]
? spend-sparrow/mocks [no test files]
ok spend-sparrow/service (cached)
? spend-sparrow/template [no test files]
? spend-sparrow/template/account [no test files]
? spend-sparrow/template/auth [no test files]
? spend-sparrow/template/mail [no test files]
? spend-sparrow/template/workout [no test files]
? spend-sparrow/types [no test files]
? spend-sparrow/utils [no test files]
FAIL

View File

@@ -8,6 +8,7 @@ import (
type User struct {
Id uuid.UUID
GroupId uuid.UUID
Email string
EmailVerified bool
EmailVerifiedAt *time.Time

91
types/money.go Normal file
View File

@@ -0,0 +1,91 @@
package types
import (
"time"
"github.com/google/uuid"
)
// At the center of the application is the transaction.
//
// Every piece of data should be calculated based on transactions. This means potential calculation errors can be fixed later in time.
//
// If it becomes necessary to precalculate snapshots for performance reasons, this can be done in the future. But the transaction should always be the source of truth.
type Transaction struct {
Id uuid.UUID
GroupId uuid.UUID
AccountId uuid.UUID
// nil indicates that the transaction is not yet associated with a piggy bank
PiggyBankId *uuid.UUID
// The internal transaction is amove between e.g. an account and a piggy bank to execute a savings plan
Internal bool
// The value of the transacion. Negative for outgoing and positive for incoming
Value int64
Timestamp time.Time
Note string
CreatedAt time.Time
// either "<username>" or "system-<subsystem>"
CreatedBy uuid.UUID
UpdatedAt time.Time
UpdatedBy uuid.UUID
}
// The Account holds money
type Account struct {
Id uuid.UUID
GroupId uuid.UUID
// Custom Name of the account, e.g. "Bank", "Cash", "Credit Card"
Name string
CurrentBalance int64
LastTransaction *time.Time
// The current precalculated value of:
// Account.Balance - [PiggyBank.Balance...]
OinkBalance int64
CreatedAt time.Time
CreatedBy uuid.UUID
UpdatedAt *time.Time
UpdatedBy *uuid.UUID
}
// The PiggyBank is a fictional account. The money it "holds" is actually in the Account
type PiggyBank struct {
Id uuid.UUID
GroupId uuid.UUID
AccountId uuid.UUID
Name string
CurrentBalance int64
CreatedAt time.Time
CreatedBy uuid.UUID
UpdatedAt *time.Time
UpdatedBy *uuid.UUID
}
// The SavingsPlan is applied every interval to the PiggyBank/Account as a transaction
type SavingsPlan struct {
Id uuid.UUID
GroupId uuid.UUID
PiggyBankId uuid.UUID
MonthlySaving int64
ValidFrom time.Time
/// nil means it is valid indefinitely
ValidTo *time.Time
CreatedAt time.Time
CreatedBy uuid.UUID
UpdatedAt *time.Time
UpdatedBy *uuid.UUID
}

View File

@@ -6,4 +6,5 @@ import (
var (
ErrInternal = errors.New("internal server error")
ErrUnauthorized = errors.New("You are not authorized to perform this action.")
)