initial commit. Cloned timetracker repository
This commit is contained in:
509
app/service/authService/auth.go
Normal file
509
app/service/authService/auth.go
Normal 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
|
||||
}
|
||||
204
app/service/authService/google_oauth.go
Normal file
204
app/service/authService/google_oauth.go
Normal 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
|
||||
}
|
||||
138
app/service/emailService/email.go
Normal file
138
app/service/emailService/email.go
Normal 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()
|
||||
}
|
||||
97
app/service/langs/service.go
Normal file
97
app/service/langs/service.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
335
app/service/repoService/repo.go
Normal file
335
app/service/repoService/repo.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user