timetracker update

This commit is contained in:
Daniel Goc
2026-03-11 09:33:36 +01:00
parent bbf8a2c133
commit 9ef4bb219b
121 changed files with 4328 additions and 2231 deletions

View File

@@ -13,7 +13,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/service/emailService"
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"github.com/dlclark/regexp2"
"github.com/golang-jwt/jwt/v5"
@@ -60,23 +60,23 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, strin
// Find user by email
if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidCredentials
return nil, "", responseErrors.ErrInvalidCredentials
}
return nil, "", fmt.Errorf("database error: %w", err)
}
// Check if user is active
if !user.IsActive {
return nil, "", view.ErrUserInactive
return nil, "", responseErrors.ErrUserInactive
}
// Check if email is verified
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, "", view.ErrInvalidCredentials
return nil, "", responseErrors.ErrInvalidCredentials
}
// Update last login time
@@ -109,17 +109,17 @@ func (s *AuthService) Register(req *model.RegisterRequest) error {
// Check if email already exists
var existingUser model.Customer
if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
return view.ErrEmailExists
return responseErrors.ErrEmailExists
}
// Validate passwords match
if req.Password != req.ConfirmPassword {
return view.ErrPasswordsDoNotMatch
return responseErrors.ErrPasswordsDoNotMatch
}
// Validate password strength
if err := validatePassword(req.Password); err != nil {
return view.ErrInvalidPassword
return responseErrors.ErrInvalidPassword
}
// Hash password
@@ -176,14 +176,14 @@ func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationReques
var user model.Customer
if err := s.db.Where("email_verification_token = ?", req.Token).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidVerificationToken
return nil, "", responseErrors.ErrInvalidVerificationToken
}
return nil, "", fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) {
return nil, "", view.ErrVerificationTokenExpired
return nil, "", responseErrors.ErrVerificationTokenExpired
}
// Update user - activate account and mark email as verified
@@ -283,19 +283,19 @@ func (s *AuthService) ResetPassword(token, newPassword string) error {
var user model.Customer
if err := s.db.Where("password_reset_token = ?", token).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return view.ErrInvalidResetToken
return responseErrors.ErrInvalidResetToken
}
return fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
return view.ErrResetTokenExpired
return responseErrors.ErrResetTokenExpired
}
// Validate new password
if err := validatePassword(newPassword); err != nil {
return view.ErrInvalidPassword
return responseErrors.ErrInvalidPassword
}
// Hash new password
@@ -330,14 +330,14 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, view.ErrTokenExpired
return nil, responseErrors.ErrTokenExpired
}
return nil, view.ErrInvalidToken
return nil, responseErrors.ErrInvalidToken
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, view.ErrInvalidToken
return nil, responseErrors.ErrInvalidToken
}
return claims, nil
@@ -352,7 +352,7 @@ func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string
var rt model.RefreshToken
if err := s.db.Where("token_hash = ?", tokenHash).First(&rt).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", view.ErrInvalidToken
return nil, "", responseErrors.ErrInvalidToken
}
return nil, "", fmt.Errorf("database error: %w", err)
}
@@ -361,21 +361,21 @@ func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string
if rt.ExpiresAt.Before(time.Now()) {
// Clean up expired token
s.db.Delete(&rt)
return nil, "", view.ErrTokenExpired
return nil, "", responseErrors.ErrTokenExpired
}
// Get user from database
var user model.Customer
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
return nil, "", view.ErrUserNotFound
return nil, "", responseErrors.ErrUserNotFound
}
if !user.IsActive {
return nil, "", view.ErrUserInactive
return nil, "", responseErrors.ErrUserInactive
}
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Delete the old refresh token (rotation: one-time use)
@@ -420,7 +420,7 @@ func (s *AuthService) GetUserByID(userID uint) (*model.Customer, error) {
var user model.Customer
if err := s.db.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, view.ErrUserNotFound
return nil, responseErrors.ErrUserNotFound
}
return nil, fmt.Errorf("database error: %w", err)
}
@@ -432,7 +432,7 @@ func (s *AuthService) GetUserByEmail(email string) (*model.Customer, error) {
var user model.Customer
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, view.ErrUserNotFound
return nil, responseErrors.ErrUserNotFound
}
return nil, fmt.Errorf("database error: %w", err)
}

View File

@@ -12,6 +12,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"git.ma-al.com/goc_marek/timetracker/app/view"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@@ -19,17 +20,6 @@ import (
const googleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
// GoogleUserInfo represents the user info returned by Google
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
// googleOAuthConfig returns the OAuth2 config for Google
func googleOAuthConfig() *oauth2.Config {
cfg := config.Get().OAuth.Google
@@ -81,7 +71,7 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st
}
if !userInfo.VerifiedEmail {
return nil, "", view.ErrEmailNotVerified
return nil, "", responseErrors.ErrEmailNotVerified
}
// Find or create user
@@ -117,7 +107,7 @@ func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, st
// findOrCreateGoogleUser finds an existing user by Google provider ID or email,
// or creates a new one.
func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Customer, error) {
func (s *AuthService) findOrCreateGoogleUser(info *view.GoogleUserInfo) (*model.Customer, error) {
var user model.Customer
// Try to find by provider + provider_id
@@ -183,7 +173,7 @@ func (s *AuthService) findOrCreateGoogleUser(info *GoogleUserInfo) (*model.Custo
}
// fetchGoogleUserInfo fetches user info from Google using the provided HTTP client
func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
func fetchGoogleUserInfo(client *http.Client) (*view.GoogleUserInfo, error) {
resp, err := client.Get(googleUserInfoURL)
if err != nil {
return nil, err
@@ -195,7 +185,7 @@ func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
return nil, err
}
var userInfo GoogleUserInfo
var userInfo view.GoogleUserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, err
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"git.ma-al.com/goc_marek/timetracker/app/config"
"git.ma-al.com/goc_marek/timetracker/app/service/langs"
"git.ma-al.com/goc_marek/timetracker/app/service/langsService"
"git.ma-al.com/goc_marek/timetracker/app/templ/emails"
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
"git.ma-al.com/goc_marek/timetracker/app/view"
@@ -32,7 +32,7 @@ func getLangID(isoCode string) uint {
isoCode = "en"
}
lang, err := langs.LangSrv.GetLanguageByISOCode(isoCode)
lang, err := langsService.LangSrv.GetLanguageByISOCode(isoCode)
if err != nil || lang == nil {
return 1 // Default to English (ID 1)
}

View File

@@ -1,4 +1,4 @@
package langs
package langsService
import (
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"

View File

@@ -7,7 +7,7 @@ import (
"git.ma-al.com/goc_marek/timetracker/app/db"
"git.ma-al.com/goc_marek/timetracker/app/model"
"git.ma-al.com/goc_marek/timetracker/app/utils/pagination"
"git.ma-al.com/goc_marek/timetracker/app/view"
"git.ma-al.com/goc_marek/timetracker/app/utils/responseErrors"
"gorm.io/gorm"
)
@@ -45,7 +45,7 @@ func (s *RepoService) UserHasAccessToRepo(userID uint, repoID uint) (bool, error
}
if !slices.Contains(repositories, repoID) {
return false, view.ErrInvalidRepoID
return false, responseErrors.ErrInvalidRepoID
}
return true, nil
@@ -147,140 +147,19 @@ func (s *RepoService) GetQuarters(repo uint, year uint) ([]model.QuarterData, er
Find(&quarters).
Error
if err != nil {
fmt.Printf("err: %v\n", err)
return nil, err
}
return quarters, nil
}
func (s *RepoService) GetTotalTimeForQuarter(repo uint, year uint, quarter uint) (float64, error) {
var total float64
query := `
SELECT COALESCE(SUM(tt.time) / 3600, 0) AS total_time
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
WHERE i.repo_id = ?
AND EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
AND tt.deleted = false
`
err := db.Get().Raw(query, repo, year, quarter).Row().Scan(&total)
if err != nil {
return 0, err
}
return total, nil
}
func (s *RepoService) GetTimeTracked(repo uint, year uint, quarter uint, step string) ([]model.DayData, error) {
var days []model.DayData
// Calculate quarter start and end dates
quarterStartMonth := (quarter-1)*3 + 1
quarterStart := fmt.Sprintf("%d-%02d-01", year, quarterStartMonth)
var quarterEnd string
switch quarter {
case 1:
quarterEnd = fmt.Sprintf("%d-03-31", year)
case 2:
quarterEnd = fmt.Sprintf("%d-06-30", year)
case 3:
quarterEnd = fmt.Sprintf("%d-09-30", year)
default:
quarterEnd = fmt.Sprintf("%d-12-31", year)
}
var bucketExpr string
var seriesInterval string
var seriesStart string
var seriesEnd string
switch step {
case "day":
bucketExpr = "DATE(to_timestamp(tt.created_unix))"
seriesInterval = "1 day"
seriesStart = "p.start_date"
seriesEnd = "p.end_date"
case "week":
bucketExpr = `
(p.start_date +
((DATE(to_timestamp(tt.created_unix)) - p.start_date) / 7) * 7
)::date`
seriesInterval = "7 days"
seriesStart = "p.start_date"
seriesEnd = "p.end_date"
case "month":
bucketExpr = "date_trunc('month', to_timestamp(tt.created_unix))::date"
seriesInterval = "1 month"
seriesStart = "date_trunc('month', p.start_date)"
seriesEnd = "date_trunc('month', p.end_date)"
}
query := fmt.Sprintf(`
WITH params AS (
SELECT ?::date AS start_date, ?::date AS end_date
),
date_range AS (
SELECT generate_series(
%s,
%s,
interval '%s'
)::date AS date
FROM params p
),
data AS (
SELECT
%s AS date,
SUM(tt.time) / 3600 AS time
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
CROSS JOIN params p
WHERE i.repo_id = ?
AND to_timestamp(tt.created_unix) >= p.start_date
AND to_timestamp(tt.created_unix) < p.end_date + interval '1 day'
AND tt.deleted = false
GROUP BY 1
)
SELECT
TO_CHAR(dr.date, 'YYYY-MM-DD') AS date,
COALESCE(d.time, 0) AS time
FROM date_range dr
LEFT JOIN data d ON d.date = dr.date
ORDER BY dr.date
`, seriesStart, seriesEnd, seriesInterval, bucketExpr)
err := db.Get().
Raw(query, quarterStart, quarterEnd, repo).
Scan(&days).Error
if err != nil {
return nil, err
}
return days, nil
}
func (s *RepoService) GetRepoData(repoIds []uint) ([]model.Repository, error) {
var repos []model.Repository
err := db.Get().Model(model.Repository{}).Where("id = ?", repoIds).Find(&repos).Error
if err != nil {
return nil, err
}
return repos, nil
}
func (s *RepoService) GetIssuesForUser(
userID uint,
repoID uint,
year uint,
quarter uint,
p pagination.Paging,
) (*pagination.Found[view.IssueTimeSummary], error) {
) (*pagination.Found[model.IssueTimeSummary], error) {
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
return nil, err
}
@@ -293,21 +172,14 @@ func (s *RepoService) GetIssues(
year uint,
quarter uint,
p pagination.Paging,
) (*pagination.Found[view.IssueTimeSummary], error) {
) (*pagination.Found[model.IssueTimeSummary], error) {
query := db.Get().Debug().
query := db.Get().
Table("issue i").
Select(`
i.id AS issue_id,
i.name AS issue_name,
u.id AS user_id,
upper(
regexp_replace(
regexp_replace(u.full_name, '(\y\w)\w*', '\1', 'g'),
'(\w)', '\1.', 'g'
)
) AS initials,
to_timestamp(tt.created_unix)::date AS created_date,
to_timestamp(i.created_unix) AS issue_created_at,
ROUND(SUM(tt.time) / 3600.0, 2) AS total_hours_spent
`).
Joins(`JOIN tracked_time tt ON tt.issue_id = i.id`).
@@ -321,12 +193,11 @@ func (s *RepoService) GetIssues(
i.id,
i.name,
u.id,
u.full_name,
created_date
u.full_name
`).
Order("created_date")
Order("i.created_unix")
result, err := pagination.Paginate[view.IssueTimeSummary](p, query)
result, err := pagination.Paginate[model.IssueTimeSummary](p, query)
if err != nil {
return nil, err
}