initial commit. Cloned timetracker repository

This commit is contained in:
Daniel Goc
2026-03-10 09:02:57 +01:00
commit f2952bcef0
189 changed files with 21334 additions and 0 deletions

View File

@@ -0,0 +1,509 @@
package authService
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"git.ma-al.com/goc_marek/timetracker/app/config"
"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/service/emailService"
constdata "git.ma-al.com/goc_marek/timetracker/app/utils/const_data"
"git.ma-al.com/goc_marek/timetracker/app/view"
"github.com/dlclark/regexp2"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// JWTClaims represents the JWT claims
type JWTClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
Role model.CustomerRole `json:"customer_role"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
jwt.RegisteredClaims
}
// AuthService handles authentication operations
type AuthService struct {
db *gorm.DB
config *config.AuthConfig
email *emailService.EmailService
}
// NewAuthService creates a new AuthService instance
func NewAuthService() *AuthService {
svc := &AuthService{
db: db.Get(),
config: &config.Get().Auth,
email: emailService.NewEmailService(),
}
// Auto-migrate the refresh_tokens table
if svc.db != nil {
_ = svc.db.AutoMigrate(&model.RefreshToken{})
}
return svc
}
// Login authenticates a user with email and password
func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, string, error) {
var user model.Customer
// 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, "", fmt.Errorf("database error: %w", err)
}
// Check if user is active
if !user.IsActive {
return nil, "", view.ErrUserInactive
}
// Check if email is verified
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return nil, "", view.ErrInvalidCredentials
}
// Update last login time
now := time.Now()
user.LastLoginAt = &now
s.db.Save(&user)
// Generate access token (JWT)
accessToken, err := s.generateAccessToken(&user)
if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
}
// Generate opaque refresh token and store in DB
rawRefreshToken, err := s.createRefreshToken(user.ID)
if err != nil {
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
}
return &model.AuthResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: s.config.JWTExpiration,
User: user.ToSession(),
}, rawRefreshToken, nil
}
// Register initiates user registration
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
}
// Validate passwords match
if req.Password != req.ConfirmPassword {
return view.ErrPasswordsDoNotMatch
}
// Validate password strength
if err := validatePassword(req.Password); err != nil {
return view.ErrInvalidPassword
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Generate verification token
token, err := s.generateVerificationToken()
if err != nil {
return fmt.Errorf("failed to generate verification token: %w", err)
}
// Set expiration (24 hours from now)
expiresAt := time.Now().Add(24 * time.Hour)
// Create user with verification token
user := model.Customer{
Email: req.Email,
Password: string(hashedPassword),
FirstName: req.FirstName,
LastName: req.LastName,
Role: model.RoleUser,
Provider: model.ProviderLocal,
IsActive: false,
EmailVerified: false,
EmailVerificationToken: token,
EmailVerificationExpires: &expiresAt,
Lang: req.Lang,
}
if err := s.db.Create(&user).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
// Send verification email
baseURL := config.Get().App.BaseURL
lang := req.Lang
if lang == "" {
lang = "en" // Default to English
}
if err := s.email.SendVerificationEmail(user.Email, user.EmailVerificationToken, baseURL, lang); err != nil {
// Log error but don't fail registration - user can request resend
_ = err
}
return nil
}
// CompleteRegistration completes the registration with password verification after email verification
func (s *AuthService) CompleteRegistration(req *model.CompleteRegistrationRequest) (*model.AuthResponse, string, error) {
// Find user by verification token
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, "", fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.EmailVerificationExpires != nil && user.EmailVerificationExpires.Before(time.Now()) {
return nil, "", view.ErrVerificationTokenExpired
}
// Update user - activate account and mark email as verified
user.IsActive = true
user.EmailVerified = true
user.EmailVerificationToken = ""
user.EmailVerificationExpires = nil
if err := s.db.Save(&user).Error; err != nil {
return nil, "", fmt.Errorf("failed to update user: %w", err)
}
// Send admin notification about new user registration
baseURL := config.Get().App.BaseURL
if err := s.email.SendNewUserAdminNotification(user.Email, user.FullName(), baseURL); err != nil {
_ = err
}
// Generate access token
accessToken, err := s.generateAccessToken(&user)
if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
}
// Generate opaque refresh token and store in DB
rawRefreshToken, err := s.createRefreshToken(user.ID)
if err != nil {
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
}
return &model.AuthResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: s.config.JWTExpiration,
User: user.ToSession(),
}, rawRefreshToken, nil
}
// RequestPasswordReset initiates a password reset request
func (s *AuthService) RequestPasswordReset(emailAddr string) error {
// Find user by email
var user model.Customer
if err := s.db.Where("email = ?", emailAddr).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Don't reveal if email exists or not for security
return nil
}
return fmt.Errorf("database error: %w", err)
}
// Check if user is active, email verified, and is a local user
if !user.IsActive || !user.EmailVerified || user.Provider != model.ProviderLocal {
// Don't reveal account status for security
return nil
}
// Check rate limit: don't allow password reset requests more than once per hour
if user.LastPasswordResetRequest != nil && time.Since(*user.LastPasswordResetRequest) < time.Hour {
// Rate limit hit, silently fail for security
return nil
}
// Generate reset token
token, err := s.generateVerificationToken()
if err != nil {
return fmt.Errorf("failed to generate reset token: %w", err)
}
// Set expiration (1 hour from now)
expiresAt := time.Now().Add(1 * time.Hour)
// Update user with reset token and last request time
now := time.Now()
user.PasswordResetToken = token
user.PasswordResetExpires = &expiresAt
user.LastPasswordResetRequest = &now
if err := s.db.Save(&user).Error; err != nil {
return fmt.Errorf("failed to save reset token: %w", err)
}
// Send password reset email
baseURL := config.Get().App.BaseURL
lang := "en"
if user.Lang != "" {
lang = user.Lang
}
if err := s.email.SendPasswordResetEmail(user.Email, user.PasswordResetToken, baseURL, lang); err != nil {
_ = err
}
return nil
}
// ResetPassword completes the password reset with a new password
func (s *AuthService) ResetPassword(token, newPassword string) error {
// Find user by reset token
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 fmt.Errorf("database error: %w", err)
}
// Check if token is expired
if user.PasswordResetExpires == nil || user.PasswordResetExpires.Before(time.Now()) {
return view.ErrResetTokenExpired
}
// Validate new password
if err := validatePassword(newPassword); err != nil {
return view.ErrInvalidPassword
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// Update user password and clear reset token
user.Password = string(hashedPassword)
user.PasswordResetToken = ""
user.PasswordResetExpires = nil
if err := s.db.Save(&user).Error; err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Revoke all existing refresh tokens for this user (security: password changed)
s.db.Where("customer_id = ?", user.ID).Delete(&model.RefreshToken{})
return nil
}
// ValidateToken validates a JWT access token and returns the claims
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.config.JWTSecret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, view.ErrTokenExpired
}
return nil, view.ErrInvalidToken
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, view.ErrInvalidToken
}
return claims, nil
}
// RefreshToken validates an opaque refresh token, rotates it, and returns a new access token.
// Returns: AuthResponse, new raw refresh token, error
func (s *AuthService) RefreshToken(rawToken string) (*model.AuthResponse, string, error) {
tokenHash := hashToken(rawToken)
// Find the refresh token record
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, "", fmt.Errorf("database error: %w", err)
}
// Check expiry
if rt.ExpiresAt.Before(time.Now()) {
// Clean up expired token
s.db.Delete(&rt)
return nil, "", view.ErrTokenExpired
}
// Get user from database
var user model.Customer
if err := s.db.First(&user, rt.CustomerID).Error; err != nil {
return nil, "", view.ErrUserNotFound
}
if !user.IsActive {
return nil, "", view.ErrUserInactive
}
if !user.EmailVerified {
return nil, "", view.ErrEmailNotVerified
}
// Delete the old refresh token (rotation: one-time use)
s.db.Delete(&rt)
// Generate new access token
accessToken, err := s.generateAccessToken(&user)
if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
}
// Issue a new opaque refresh token
newRawRefreshToken, err := s.createRefreshToken(user.ID)
if err != nil {
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
}
return &model.AuthResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: s.config.JWTExpiration,
User: user.ToSession(),
}, newRawRefreshToken, nil
}
// RevokeRefreshToken deletes a specific refresh token from the DB (used on logout)
func (s *AuthService) RevokeRefreshToken(rawToken string) {
if rawToken == "" {
return
}
tokenHash := hashToken(rawToken)
s.db.Where("token_hash = ?", tokenHash).Delete(&model.RefreshToken{})
}
// RevokeAllRefreshTokens deletes all refresh tokens for a user (used on logout-all-devices)
func (s *AuthService) RevokeAllRefreshTokens(userID uint) {
s.db.Where("customer_id = ?", userID).Delete(&model.RefreshToken{})
}
// GetUserByID retrieves a user by ID
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, fmt.Errorf("database error: %w", err)
}
return &user, nil
}
// GetUserByEmail retrieves a user by email
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, fmt.Errorf("database error: %w", err)
}
return &user, nil
}
// createRefreshToken generates a random opaque token, stores its hash in the DB, and returns the raw token.
func (s *AuthService) createRefreshToken(userID uint) (string, error) {
// Generate 32 random bytes → 64-char hex string
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
rawToken := hex.EncodeToString(b)
tokenHash := hashToken(rawToken)
expiresAt := time.Now().Add(time.Duration(s.config.RefreshExpiration) * time.Second)
rt := model.RefreshToken{
CustomerID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
}
if err := s.db.Create(&rt).Error; err != nil {
return "", fmt.Errorf("failed to store refresh token: %w", err)
}
return rawToken, nil
}
// hashToken returns the SHA-256 hex digest of a raw token string.
func hashToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}
// generateAccessToken generates a short-lived JWT access token
func (s *AuthService) generateAccessToken(user *model.Customer) (string, error) {
claims := JWTClaims{
UserID: user.ID,
Email: user.Email,
Username: user.Email,
Role: user.Role,
FirstName: user.FirstName,
LastName: user.LastName,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(s.config.JWTExpiration) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.config.JWTSecret))
}
// generateVerificationToken generates a random verification token
func (s *AuthService) generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// validatePassword validates password strength using RE2-compatible regexes.
func validatePassword(password string) error {
var passregex2 = regexp2.MustCompile(constdata.PASSWORD_VALIDATION_REGEX, regexp2.None)
if ok, _ := passregex2.MatchString(password); !ok {
return errors.New("password must be at least 10 characters long and contain at least one lowercase letter, one uppercase letter, and one digit")
}
return nil
}

View File

@@ -0,0 +1,204 @@
package authService
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"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/view"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
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
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
}
}
return &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Scopes: scopes,
Endpoint: google.Endpoint,
}
}
// GenerateOAuthState generates a random state token for CSRF protection
func (s *AuthService) GenerateOAuthState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// GetGoogleAuthURL returns the Google OAuth2 authorization URL with a state token
func (s *AuthService) GetGoogleAuthURL(state string) string {
return googleOAuthConfig().AuthCodeURL(state, oauth2.AccessTypeOnline)
}
// HandleGoogleCallback exchanges the code for a token, fetches user info,
// and either logs in or registers the user, returning an AuthResponse and raw refresh token.
func (s *AuthService) HandleGoogleCallback(code string) (*model.AuthResponse, string, error) {
oauthCfg := googleOAuthConfig()
// Exchange authorization code for token
token, err := oauthCfg.Exchange(context.Background(), code)
if err != nil {
return nil, "", fmt.Errorf("failed to exchange code: %w", err)
}
// Fetch user info from Google
userInfo, err := fetchGoogleUserInfo(oauthCfg.Client(context.Background(), token))
if err != nil {
return nil, "", fmt.Errorf("failed to fetch user info: %w", err)
}
if !userInfo.VerifiedEmail {
return nil, "", view.ErrEmailNotVerified
}
// Find or create user
user, err := s.findOrCreateGoogleUser(userInfo)
if err != nil {
return nil, "", err
}
// Update last login
now := time.Now()
user.LastLoginAt = &now
s.db.Save(user)
// Generate access token (JWT)
accessToken, err := s.generateAccessToken(user)
if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
}
// Generate opaque refresh token and store in DB
rawRefreshToken, err := s.createRefreshToken(user.ID)
if err != nil {
return nil, "", fmt.Errorf("failed to create refresh token: %w", err)
}
return &model.AuthResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: s.config.JWTExpiration,
User: user.ToSession(),
}, rawRefreshToken, nil
}
// 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) {
var user model.Customer
// Try to find by provider + provider_id
err := s.db.Where("provider = ? AND provider_id = ?", model.ProviderGoogle, info.ID).First(&user).Error
if err == nil {
// Update avatar in case it changed
user.AvatarURL = info.Picture
s.db.Save(&user)
return &user, nil
}
// Try to find by email (user may have registered locally before)
err = s.db.Where("email = ?", info.Email).First(&user).Error
if err == nil {
// Link Google provider to existing account
user.Provider = model.ProviderGoogle
user.ProviderID = info.ID
user.AvatarURL = info.Picture
user.IsActive = true
s.db.Save(&user)
// If email has not been verified yet, send email to admin.
if !user.EmailVerified {
baseURL := config.Get().App.BaseURL
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
// Log error but don't fail registration
_ = err
}
}
user.EmailVerified = true
return &user, nil
}
// Create new user
newUser := model.Customer{
Email: info.Email,
FirstName: info.GivenName,
LastName: info.FamilyName,
Provider: model.ProviderGoogle,
ProviderID: info.ID,
AvatarURL: info.Picture,
Role: model.RoleUser,
IsActive: true,
EmailVerified: true,
Lang: "en",
}
if err := s.db.Create(&newUser).Error; err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// If everything succeeded, send email to admin.
if !user.EmailVerified {
baseURL := config.Get().App.BaseURL
if err := s.email.SendNewUserAdminNotification(info.Email, info.Name, baseURL); err != nil {
// Log error but don't fail registration
_ = err
}
}
return &newUser, nil
}
// fetchGoogleUserInfo fetches user info from Google using the provided HTTP client
func fetchGoogleUserInfo(client *http.Client) (*GoogleUserInfo, error) {
resp, err := client.Get(googleUserInfoURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var userInfo GoogleUserInfo
if err := json.Unmarshal(body, &userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}

View File

@@ -0,0 +1,138 @@
package emailService
import (
"bytes"
"context"
"fmt"
"net/smtp"
"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/templ/emails"
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
"git.ma-al.com/goc_marek/timetracker/app/view"
)
// EmailService handles sending emails
type EmailService struct {
config *config.EmailConfig
}
// NewEmailService creates a new EmailService instance
func NewEmailService() *EmailService {
return &EmailService{
config: &config.Get().Email,
}
}
// getLangID returns the language ID from the ISO code using the language service
func getLangID(isoCode string) uint {
if isoCode == "" {
isoCode = "en"
}
lang, err := langs.LangSrv.GetLanguageByISOCode(isoCode)
if err != nil || lang == nil {
return 1 // Default to English (ID 1)
}
return uint(lang.ID)
}
// SendEmail sends an email to the specified recipient
func (s *EmailService) SendEmail(to, subject, body string) error {
if !s.config.Enabled {
return fmt.Errorf("email service is disabled")
}
// Set up authentication
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost)
// Create email headers
headers := make(map[string]string)
headers["From"] = fmt.Sprintf("%s <%s>", s.config.FromName, s.config.FromEmail)
headers["To"] = to
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=utf-8"
// Build email message
var msg strings.Builder
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(body)
// Send email
addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort)
if err := smtp.SendMail(addr, auth, s.config.FromEmail, []string{to}, []byte(msg.String())); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
// SendVerificationEmail sends an email verification email
func (s *EmailService) SendVerificationEmail(to, token, baseURL, lang string) error {
// Use default language if not provided
if lang == "" {
lang = "en"
}
verificationURL := fmt.Sprintf("%s/%s/verify-email?token=%s", baseURL, lang, token)
langID := getLangID(lang)
subject := i18n.T___(langID, "email.email_verification_subject")
body := s.verificationEmailTemplate(to, verificationURL, langID)
return s.SendEmail(to, subject, body)
}
// SendPasswordResetEmail sends a password reset email
func (s *EmailService) SendPasswordResetEmail(to, token, baseURL, lang string) error {
// Use default language if not provided
if lang == "" {
lang = "en"
}
resetURL := fmt.Sprintf("%s/%s/reset-password?token=%s", baseURL, lang, token)
langID := getLangID(lang)
subject := i18n.T___(langID, "email.email_password_reset_subject")
body := s.passwordResetEmailTemplate(to, resetURL, langID)
return s.SendEmail(to, subject, body)
}
// SendNewUserAdminNotification sends an email to admin when a new user completes registration
func (s *EmailService) SendNewUserAdminNotification(userEmail, userName, baseURL string) error {
if s.config.AdminEmail == "" {
return nil // No admin email configured
}
subject := "New User Registration - Repository Assignment Required"
body := s.newUserAdminNotificationTemplate(userEmail, userName, baseURL)
return s.SendEmail(s.config.AdminEmail, subject, body)
}
// verificationEmailTemplate returns the HTML template for email verification
func (s *EmailService) verificationEmailTemplate(name, verificationURL string, langID uint) string {
buf := bytes.Buffer{}
emails.EmailVerificationWrapper(view.EmailLayout[view.EmailVerificationData]{LangID: langID, Data: view.EmailVerificationData{VerificationURL: verificationURL}}).Render(context.Background(), &buf)
return buf.String()
}
// passwordResetEmailTemplate returns the HTML template for password reset
func (s *EmailService) passwordResetEmailTemplate(name, resetURL string, langID uint) string {
buf := bytes.Buffer{}
emails.EmailPasswordResetWrapper(view.EmailLayout[view.EmailPasswordResetData]{LangID: langID, Data: view.EmailPasswordResetData{ResetURL: resetURL}}).Render(context.Background(), &buf)
return buf.String()
}
// newUserAdminNotificationTemplate returns the HTML template for admin notification
func (s *EmailService) newUserAdminNotificationTemplate(userEmail, userName, baseURL string) string {
buf := bytes.Buffer{}
emails.EmailAdminNotificationWrapper(view.EmailLayout[view.EmailAdminNotificationData]{LangID: 2, Data: view.EmailAdminNotificationData{UserEmail: userEmail, UserName: userName, BaseURL: baseURL}}).Render(context.Background(), &buf)
return buf.String()
}

View File

@@ -0,0 +1,97 @@
package langs
import (
langs_repo "git.ma-al.com/goc_marek/timetracker/app/langs"
"git.ma-al.com/goc_marek/timetracker/app/utils/i18n"
"git.ma-al.com/goc_marek/timetracker/app/utils/nullable"
"git.ma-al.com/goc_marek/timetracker/app/utils/response"
"git.ma-al.com/goc_marek/timetracker/app/view"
"github.com/gofiber/fiber/v3"
)
type LangService struct {
repo langs_repo.LangsRepo
}
type LangServiceMessage i18n.I18nTranslation
const (
Message_LangsLoaded LangServiceMessage = "langs_loaded"
Message_LangsNotLoaded LangServiceMessage = "langs_not_loaded"
Message_TranslationsOK LangServiceMessage = "translations_loaded"
Message_TranslationsNOK LangServiceMessage = "translations_not_loaded"
)
var LangSrv *LangService
func (s *LangService) GetActive(c fiber.Ctx) response.Response[[]view.Language] {
res, err := s.repo.GetActive()
if err != nil {
return response.Make[[]view.Language](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, response.Message_NOK))
}
return response.Make(c, fiber.StatusOK, nullable.GetNil(res), nullable.GetNil(len(res)), i18n.T_(c, response.Message_OK))
}
// LoadTranslations loads all translations from the database into the cache
func (s *LangService) LoadTranslations() error {
translations, err := s.repo.GetAllTranslations()
if err != nil {
return err
}
return i18n.TransStore.LoadTranslations(translations)
}
// ReloadTranslations reloads translations from the database into the cache
func (s *LangService) ReloadTranslations() error {
translations, err := s.repo.GetAllTranslations()
if err != nil {
return err
}
return i18n.TransStore.ReloadTranslations(translations)
}
// GetTranslations returns translations from the cache
func (s *LangService) GetTranslations(c fiber.Ctx, langID uint, scope string, components []string) response.Response[*i18n.TranslationResponse] {
translations, err := i18n.TransStore.GetTranslations(langID, scope, components)
if err != nil {
return response.Make[*i18n.TranslationResponse](c, fiber.StatusBadRequest, nil, nil, i18n.T_(c, Message_TranslationsNOK))
}
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
}
// GetAllTranslations returns all translations from the cache
func (s *LangService) GetAllTranslationsResponse(c fiber.Ctx) response.Response[*i18n.TranslationResponse] {
translations := i18n.TransStore.GetAllTranslations()
return response.Make(c, fiber.StatusOK, nullable.GetNil(translations), nil, i18n.T_(c, Message_TranslationsOK))
}
// ReloadTranslationsResponse returns response after reloading translations
func (s *LangService) ReloadTranslationsResponse(c fiber.Ctx) response.Response[map[string]string] {
err := s.ReloadTranslations()
if err != nil {
return response.Make[map[string]string](c, fiber.StatusInternalServerError, nil, nil, i18n.T_(c, Message_LangsNotLoaded))
}
result := map[string]string{"status": "success"}
return response.Make(c, fiber.StatusOK, nullable.GetNil(result), nil, i18n.T_(c, Message_LangsLoaded))
}
// GetDefaultLanguage returns the default language
func (s *LangService) GetDefaultLanguage() (*view.Language, error) {
return s.repo.GetDefault()
}
// GetLanguageByISOCode returns a language by its ISO code
func (s *LangService) GetLanguageByISOCode(isoCode string) (*view.Language, error) {
return s.repo.GetByISOCode(isoCode)
}
// GetLanguageByISOCode returns a language by its ISO code
func (s *LangService) GetLanguageById(id uint) (*view.Language, error) {
return s.repo.GetById(id)
}
func init() {
LangSrv = &LangService{
repo: *langs_repo.New(),
}
}

View File

@@ -0,0 +1,335 @@
package repoService
import (
"fmt"
"slices"
"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"
"gorm.io/gorm"
)
// type
type RepoService struct {
db *gorm.DB
}
func New() *RepoService {
return &RepoService{
db: db.Get(),
}
}
func (s *RepoService) GetRepositoriesForUser(userID uint) ([]uint, error) {
var repoIDs []uint
err := s.db.
Table("customer_repo_accesses").
Where("user_id = ?", userID).
Pluck("repo_id", &repoIDs).Error
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return repoIDs, nil
}
func (s *RepoService) UserHasAccessToRepo(userID uint, repoID uint) (bool, error) {
var repositories []uint
var err error
if repositories, err = s.GetRepositoriesForUser(userID); err != nil {
return false, err
}
if !slices.Contains(repositories, repoID) {
return false, view.ErrInvalidRepoID
}
return true, nil
}
// Extract all repositories assigned to user with specific id
func (s *RepoService) GetYearsForUser(userID uint, repoID uint) ([]uint, error) {
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
return nil, err
}
years, err := s.GetYears(repoID)
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return years, nil
}
func (s *RepoService) GetYears(repo uint) ([]uint, error) {
var years []uint
query := `
WITH bounds AS (
SELECT
MIN(to_timestamp(tt.created_unix)) AS min_ts,
MAX(to_timestamp(tt.created_unix)) AS max_ts
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
WHERE i.repo_id = ?
AND tt.deleted = false
)
SELECT
EXTRACT(YEAR FROM y.year_start)::int AS year
FROM bounds
CROSS JOIN LATERAL generate_series(
date_trunc('year', min_ts),
date_trunc('year', max_ts),
interval '1 year'
) AS y(year_start)
ORDER BY year
`
err := db.Get().Raw(query, repo).Find(&years).Error
if err != nil {
return nil, err
}
return years, nil
}
// Extract all repositories assigned to user with specific id
func (s *RepoService) GetQuartersForUser(userID uint, repoID uint, year uint) ([]model.QuarterData, error) {
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
return nil, err
}
response, err := s.GetQuarters(repoID, year)
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return response, nil
}
func (s *RepoService) GetQuarters(repo uint, year uint) ([]model.QuarterData, error) {
var quarters []model.QuarterData
query := `
WITH quarters AS (
SELECT
make_date(?::int, 1, 1) + (q * interval '3 months') AS quarter_start,
q + 1 AS quarter
FROM generate_series(0, 3) AS q
),
data AS (
SELECT
EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) AS quarter,
SUM(tt.time) / 3600 AS time
FROM tracked_time tt
JOIN issue i ON i.id = tt.issue_id
JOIN repository r ON i.repo_id = r.id
WHERE
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
AND r.id = ?
AND tt.deleted = false
GROUP BY EXTRACT(QUARTER FROM to_timestamp(tt.created_unix))
)
SELECT
COALESCE(d.time, 0) AS time,
CONCAT(EXTRACT(YEAR FROM q.quarter_start)::int, '_Q', q.quarter) AS quarter
FROM quarters q
LEFT JOIN data d ON d.quarter = q.quarter
ORDER BY q.quarter
`
err := db.Get().
Raw(query, year, year, repo).
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) {
if ok, err := s.UserHasAccessToRepo(userID, repoID); !ok {
return nil, err
}
return s.GetIssues(repoID, year, quarter, p)
}
func (s *RepoService) GetIssues(
repoId uint,
year uint,
quarter uint,
p pagination.Paging,
) (*pagination.Found[view.IssueTimeSummary], error) {
query := db.Get().Debug().
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,
ROUND(SUM(tt.time) / 3600.0, 2) AS total_hours_spent
`).
Joins(`JOIN tracked_time tt ON tt.issue_id = i.id`).
Joins(`JOIN "user" u ON u.id = tt.user_id`).
Where("i.repo_id = ?", repoId).
Where(`
EXTRACT(YEAR FROM to_timestamp(tt.created_unix)) = ?
AND EXTRACT(QUARTER FROM to_timestamp(tt.created_unix)) = ?
`, year, quarter).
Group(`
i.id,
i.name,
u.id,
u.full_name,
created_date
`).
Order("created_date")
result, err := pagination.Paginate[view.IssueTimeSummary](p, query)
if err != nil {
return nil, err
}
return &result, nil
}