initial commit. Cloned timetracker repository
This commit is contained in:
4
app/utils/const_data/consts.go
Normal file
4
app/utils/const_data/consts.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package constdata
|
||||
|
||||
// PASSWORD_VALIDATION_REGEX is used by the frontend (JavaScript supports lookaheads).
|
||||
const PASSWORD_VALIDATION_REGEX = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{10,}$`
|
||||
237
app/utils/i18n/i18n.go
Normal file
237
app/utils/i18n/i18n.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/model"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type I18nTranslation string
|
||||
|
||||
type TranslationResponse map[uint]map[string]map[string]map[string]string
|
||||
|
||||
var TransStore = newTranslationsStore()
|
||||
|
||||
var (
|
||||
ErrLangIsoEmpty = errors.New("lang_id_empty")
|
||||
ErrScopeEmpty = errors.New("scope_empty")
|
||||
ErrComponentEmpty = errors.New("component_empty")
|
||||
ErrKeyEmpty = errors.New("key_empty")
|
||||
|
||||
ErrLangIsoNotFoundInCache = errors.New("lang_id_not_in_cache")
|
||||
ErrScopeNotFoundInCache = errors.New("scope_not_in_cache")
|
||||
ErrComponentNotFoundInCache = errors.New("component_not_in_cache")
|
||||
ErrKeyNotFoundInCache = errors.New("key_invalid_in_cache")
|
||||
)
|
||||
|
||||
type TranslationsStore struct {
|
||||
mutex sync.RWMutex
|
||||
cache TranslationResponse
|
||||
}
|
||||
|
||||
func newTranslationsStore() *TranslationsStore {
|
||||
service := &TranslationsStore{
|
||||
cache: make(TranslationResponse),
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) Get(langID uint, scope string, component string, key string) (string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
return "lang_id_empty", ErrLangIsoEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return fmt.Sprintf("lang_id_not_in_cache: %d", langID), ErrLangIsoNotFoundInCache
|
||||
}
|
||||
|
||||
if scope == "" {
|
||||
return "scope_empty", ErrScopeEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return fmt.Sprintf("scope_not_in_cache: %s", scope), ErrScopeNotFoundInCache
|
||||
}
|
||||
|
||||
if component == "" {
|
||||
return "component_empty", ErrComponentEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
return fmt.Sprintf("component_not_in_cache: %s", component), ErrComponentNotFoundInCache
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
return "key_empty", ErrKeyEmpty
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope][component][key]; !ok {
|
||||
return fmt.Sprintf("key_invalid_in_cache: %s", key), ErrKeyNotFoundInCache
|
||||
}
|
||||
|
||||
return s.cache[langID][scope][component][key], nil
|
||||
}
|
||||
func (s *TranslationsStore) GetTranslations(langID uint, scope string, components []string) (*TranslationResponse, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
if langID == 0 {
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr := make(TranslationResponse)
|
||||
|
||||
if scope == "" {
|
||||
tr[langID] = s.cache[langID]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
if _, ok := s.cache[langID][scope]; !ok {
|
||||
return nil, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
tr[langID] = make(map[string]map[string]map[string]string)
|
||||
if len(components) <= 0 {
|
||||
tr[langID][scope] = s.cache[langID][scope]
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
tr[langID][scope] = make(map[string]map[string]string)
|
||||
|
||||
var invalidComponents []string
|
||||
for _, component := range components {
|
||||
if _, ok := s.cache[langID][scope][component]; !ok {
|
||||
invalidComponents = append(invalidComponents, component)
|
||||
continue
|
||||
}
|
||||
tr[langID][scope][component] = s.cache[langID][scope][component]
|
||||
}
|
||||
if len(invalidComponents) > 0 {
|
||||
return &tr, fmt.Errorf("invalid translation arguments")
|
||||
}
|
||||
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
// GetAllTranslations returns all translations from the cache
|
||||
func (s *TranslationsStore) GetAllTranslations() *TranslationResponse {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
resp := make(TranslationResponse)
|
||||
for k, v := range s.cache {
|
||||
resp[k] = v
|
||||
}
|
||||
return &resp
|
||||
}
|
||||
|
||||
func (s *TranslationsStore) LoadTranslations(translations []model.Translation) error {
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
s.cache = make(TranslationResponse)
|
||||
for _, t := range translations {
|
||||
lang := uint(t.LangID)
|
||||
scp := t.Scope.Name
|
||||
cmp := t.Component.Name
|
||||
data := ""
|
||||
if t.Data != nil {
|
||||
data = *t.Data
|
||||
}
|
||||
|
||||
if _, ok := s.cache[lang]; !ok {
|
||||
s.cache[lang] = make(map[string]map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp]; !ok {
|
||||
s.cache[lang][scp] = make(map[string]map[string]string)
|
||||
}
|
||||
if _, ok := s.cache[lang][scp][cmp]; !ok {
|
||||
s.cache[lang][scp][cmp] = make(map[string]string)
|
||||
}
|
||||
s.cache[lang][scp][cmp][t.Key] = data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadTranslations reloads translations from the database
|
||||
func (s *TranslationsStore) ReloadTranslations(translations []model.Translation) error {
|
||||
return s.LoadTranslations(translations)
|
||||
}
|
||||
|
||||
// T_ is meant to be used to translate error messages and other system communicates.
|
||||
func T_[T ~string](c fiber.Ctx, key T, params ...interface{}) string {
|
||||
if langID, ok := c.Locals("langID").(uint); ok {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langID, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langID, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// T___ works exactly the same as T_ but uses just language ID instead of the whole context
|
||||
func T___[T ~string](langId uint, key T, params ...interface{}) string {
|
||||
parts := strings.Split(string(key), ".")
|
||||
|
||||
if len(parts) >= 2 {
|
||||
if trans, err := TransStore.Get(langId, "Backend", parts[0], strings.Join(parts[1:], ".")); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
} else {
|
||||
if trans, err := TransStore.Get(langId, "Backend", "be", parts[0]); err == nil {
|
||||
return Format(trans, params...)
|
||||
}
|
||||
}
|
||||
|
||||
return string(key)
|
||||
}
|
||||
|
||||
func Format(text string, params ...interface{}) string {
|
||||
text = fmt.Sprintf(text, params...)
|
||||
return text
|
||||
}
|
||||
|
||||
// T__ wraps T_ adding a conversion from string to template.HTML
|
||||
func T__(c fiber.Ctx, key string, params ...interface{}) template.HTML {
|
||||
return template.HTML(T_(c, key, params...))
|
||||
}
|
||||
|
||||
// MapKeyOnTranslationMap is a helper function to map keys on translation map
|
||||
// this is used to map keys on translation map
|
||||
//
|
||||
// example:
|
||||
// map := map[T]string{}
|
||||
// MapKeyOnTranslationMap(ctx, map, key1, key2, key3)
|
||||
func MapKeyOnTranslationMap[T ~string](c fiber.Ctx, m *map[T]string, key ...T) {
|
||||
if *m == nil {
|
||||
*m = make(map[T]string)
|
||||
}
|
||||
for _, k := range key {
|
||||
(*m)[k] = T_(c, string(k))
|
||||
}
|
||||
}
|
||||
74
app/utils/mapper/mapper.go
Normal file
74
app/utils/mapper/mapper.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package mapper
|
||||
|
||||
// Package mapper provides utilities to map fields from one struct to another
|
||||
// by matching field names (case-insensitive). Unmatched fields are left as
|
||||
// their zero values.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Map copies field values from src into dst by matching field names
|
||||
// (case-insensitive). Fields in dst that have no counterpart in src
|
||||
// are left at their zero value.
|
||||
//
|
||||
// Both dst and src must be pointers to structs.
|
||||
// Returns an error if the types do not satisfy those constraints.
|
||||
func Map(dst, src any) error {
|
||||
dstVal := reflect.ValueOf(dst)
|
||||
srcVal := reflect.ValueOf(src)
|
||||
|
||||
if dstVal.Kind() != reflect.Ptr || dstVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: dst must be a pointer to a struct, got %T", dst)
|
||||
}
|
||||
if srcVal.Kind() != reflect.Ptr || srcVal.Elem().Kind() != reflect.Struct {
|
||||
return fmt.Errorf("mapper: src must be a pointer to a struct, got %T", src)
|
||||
}
|
||||
|
||||
dstElem := dstVal.Elem()
|
||||
srcElem := srcVal.Elem()
|
||||
|
||||
// Build a lookup map of src fields: lowercase name -> field value
|
||||
srcFields := make(map[string]reflect.Value)
|
||||
for i := 0; i < srcElem.NumField(); i++ {
|
||||
f := srcElem.Type().Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
srcFields[strings.ToLower(f.Name)] = srcElem.Field(i)
|
||||
}
|
||||
|
||||
// Iterate over dst fields and copy matching src values
|
||||
for i := 0; i < dstElem.NumField(); i++ {
|
||||
dstField := dstElem.Field(i)
|
||||
dstType := dstElem.Type().Field(i)
|
||||
|
||||
if !dstType.IsExported() || !dstField.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
srcField, ok := srcFields[strings.ToLower(dstType.Name)]
|
||||
if !ok {
|
||||
// No matching src field – leave zero value in place
|
||||
continue
|
||||
}
|
||||
|
||||
if srcField.Type().AssignableTo(dstField.Type()) {
|
||||
dstField.Set(srcField)
|
||||
} else if srcField.Type().ConvertibleTo(dstField.Type()) {
|
||||
dstField.Set(srcField.Convert(dstField.Type()))
|
||||
}
|
||||
// If neither assignable nor convertible, the dst field keeps its zero value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustMap is like Map but panics on error.
|
||||
func MustMap(dst, src any) {
|
||||
if err := Map(dst, src); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
78
app/utils/mapper/mapper_test.go
Normal file
78
app/utils/mapper/mapper_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package mapper_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ma-al.com/goc_marek/timetracker/app/utils/mapper"
|
||||
)
|
||||
|
||||
// --- example structs ---
|
||||
|
||||
type UserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
}
|
||||
|
||||
type UserRecord struct {
|
||||
Name string
|
||||
Email string
|
||||
Age int
|
||||
CreatedAt string // not in src → stays ""
|
||||
Active bool // not in src → stays false
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
|
||||
func TestMap_MatchingFields(t *testing.T) {
|
||||
src := &UserInput{Name: "Alice", Email: "alice@example.com", Age: 30}
|
||||
dst := &UserRecord{}
|
||||
|
||||
if err := mapper.Map(dst, src); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if dst.Name != "Alice" {
|
||||
t.Errorf("Name: want Alice, got %s", dst.Name)
|
||||
}
|
||||
if dst.Email != "alice@example.com" {
|
||||
t.Errorf("Email: want alice@example.com, got %s", dst.Email)
|
||||
}
|
||||
if dst.Age != 30 {
|
||||
t.Errorf("Age: want 30, got %d", dst.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_UnmatchedFieldsAreZero(t *testing.T) {
|
||||
src := &UserInput{Name: "Bob"}
|
||||
dst := &UserRecord{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.CreatedAt != "" {
|
||||
t.Errorf("CreatedAt: expected empty string, got %q", dst.CreatedAt)
|
||||
}
|
||||
if dst.Active != false {
|
||||
t.Errorf("Active: expected false, got %v", dst.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_TypeConversion(t *testing.T) {
|
||||
type Src struct{ Score int32 }
|
||||
type Dst struct{ Score int64 }
|
||||
|
||||
src := &Src{Score: 99}
|
||||
dst := &Dst{}
|
||||
|
||||
mapper.MustMap(dst, src)
|
||||
|
||||
if dst.Score != 99 {
|
||||
t.Errorf("Score: want 99, got %d", dst.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_InvalidInput(t *testing.T) {
|
||||
if err := mapper.Map("not a struct", 42); err == nil {
|
||||
t.Error("expected error for non-struct inputs")
|
||||
}
|
||||
}
|
||||
6
app/utils/nullable/nullable.go
Normal file
6
app/utils/nullable/nullable.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package nullable
|
||||
|
||||
//go:fix inline
|
||||
func GetNil[T any](in T) *T {
|
||||
return new(in)
|
||||
}
|
||||
63
app/utils/pagination/pagination.go
Normal file
63
app/utils/pagination/pagination.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Paging struct {
|
||||
Page uint `json:"page_number" example:"5"`
|
||||
Elements uint `json:"elements_per_page" example:"30"`
|
||||
}
|
||||
|
||||
func (p Paging) Offset() int {
|
||||
return int(p.Elements) * int(p.Page-1)
|
||||
}
|
||||
|
||||
func (p Paging) Limit() int {
|
||||
return int(p.Elements)
|
||||
}
|
||||
|
||||
type Found[T any] struct {
|
||||
Items []T `json:"items,omitempty"`
|
||||
Count uint `json:"items_count" example:"56"`
|
||||
}
|
||||
|
||||
func Paginate[T any](paging Paging, stmt *gorm.DB) (Found[T], error) {
|
||||
var items []T
|
||||
var count int64
|
||||
|
||||
base := stmt.Session(&gorm.Session{})
|
||||
|
||||
countDB := stmt.Session(&gorm.Session{
|
||||
NewDB: true, // critical: do NOT reuse statement
|
||||
})
|
||||
|
||||
if err := countDB.
|
||||
Table("(?) as sub", base).
|
||||
Count(&count).Error; err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
err := base.
|
||||
Offset(paging.Offset()).
|
||||
Limit(paging.Limit()).
|
||||
Find(&items).
|
||||
Error
|
||||
if err != nil {
|
||||
return Found[T]{}, err
|
||||
}
|
||||
|
||||
return Found[T]{
|
||||
Items: items,
|
||||
Count: uint(count),
|
||||
}, err
|
||||
}
|
||||
|
||||
func ParsePagination(c *fiber.Ctx) Paging {
|
||||
pageNum, _ := strconv.ParseInt((*c).Query("p", "1"), 10, 64)
|
||||
pageSize, _ := strconv.ParseInt((*c).Query("elems", "10"), 10, 64)
|
||||
return Paging{Page: uint(pageNum), Elements: uint(pageSize)}
|
||||
}
|
||||
10
app/utils/response/messages.go
Normal file
10
app/utils/response/messages.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package response
|
||||
|
||||
import "git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
|
||||
|
||||
type ResponseMessage i18n.I18nTranslation
|
||||
|
||||
const (
|
||||
Message_OK ResponseMessage = "message_ok"
|
||||
Message_NOK ResponseMessage = "message_nok"
|
||||
)
|
||||
18
app/utils/response/response.go
Normal file
18
app/utils/response/response.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package response
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
type Response[T any] struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Items *T `json:"items,omitempty"`
|
||||
Count *int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func Make[T any](c fiber.Ctx, status int, items *T, count *int, message string) Response[T] {
|
||||
c.Status(status)
|
||||
return Response[T]{
|
||||
Message: message,
|
||||
Items: items,
|
||||
Count: count,
|
||||
}
|
||||
}
|
||||
136
app/utils/version/version.go
Normal file
136
app/utils/version/version.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// Version info populated at build time
|
||||
var (
|
||||
Version string // Git tag or commit hash
|
||||
Commit string // Short commit hash
|
||||
BuildDate string // Build timestamp
|
||||
)
|
||||
|
||||
// Info returns version information
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
}
|
||||
|
||||
// GetInfo returns version information
|
||||
func GetInfo() Info {
|
||||
v := Info{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuildDate: BuildDate,
|
||||
}
|
||||
|
||||
// If not set during build, try to get from git
|
||||
if v.Version == "" || v.Version == "unknown" || v.Version == "(devel)" {
|
||||
if gitVersion, gitCommit := getGitInfo(); gitVersion != "" {
|
||||
v.Version = gitVersion
|
||||
v.Commit = gitCommit
|
||||
}
|
||||
}
|
||||
|
||||
// If build date not set, use current time
|
||||
if v.BuildDate == "" {
|
||||
v.BuildDate = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// getGitInfo returns the latest tag or short commit hash and the commit hash
|
||||
func getGitInfo() (string, string) {
|
||||
// Get the current working directory
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Open the git repository
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Get the HEAD reference
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
commitHash := head.Hash().String()[:7]
|
||||
|
||||
// Get all tags
|
||||
tagIter, err := repo.Tags()
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Get the commit for HEAD
|
||||
commit, err := repo.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// Build ancestry map
|
||||
ancestry := make(map[string]bool)
|
||||
c := commit
|
||||
for c != nil {
|
||||
ancestry[c.Hash.String()] = true
|
||||
c, _ = c.Parent(0)
|
||||
}
|
||||
|
||||
// Find the most recent tag that's an ancestor of HEAD
|
||||
var latestTag string
|
||||
err = tagIter.ForEach(func(ref *plumbing.Reference) error {
|
||||
// Get the target commit
|
||||
targetHash := ref.Hash()
|
||||
|
||||
// Get the target commit
|
||||
targetCommit, err := repo.CommitObject(targetHash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this tag is an ancestor of HEAD
|
||||
checkCommit := targetCommit
|
||||
for checkCommit != nil {
|
||||
if ancestry[checkCommit.Hash.String()] {
|
||||
// Extract tag name (remove refs/tags/ prefix)
|
||||
tagName := strings.TrimPrefix(ref.Name().String(), "refs/tags/")
|
||||
if latestTag == "" || tagName > latestTag {
|
||||
latestTag = tagName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
checkCommit, _ = checkCommit.Parent(0)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
if latestTag != "" {
|
||||
return latestTag, commitHash
|
||||
}
|
||||
|
||||
return commitHash, commitHash
|
||||
}
|
||||
|
||||
// String returns a formatted version string
|
||||
func String() string {
|
||||
info := GetInfo()
|
||||
return fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s\n", info.Version, info.Commit, info.BuildDate)
|
||||
}
|
||||
Reference in New Issue
Block a user