Initial commit: MyCrib API in Go
Complete rewrite of Django REST API to Go with: - Gin web framework for HTTP routing - GORM for database operations - GoAdmin for admin panel - Gorush integration for push notifications - Redis for caching and job queues Features implemented: - User authentication (login, register, logout, password reset) - Residence management (CRUD, sharing, share codes) - Task management (CRUD, kanban board, completions) - Contractor management (CRUD, specialties) - Document management (CRUD, warranties) - Notifications (preferences, push notifications) - Subscription management (tiers, limits) Infrastructure: - Docker Compose for local development - Database migrations and seed data - Admin panel for data management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
418
internal/services/auth_service.go
Normal file
418
internal/services/auth_service.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrEmailTaken = errors.New("email already taken")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
ErrInvalidCode = errors.New("invalid verification code")
|
||||
ErrCodeExpired = errors.New("verification code expired")
|
||||
ErrAlreadyVerified = errors.New("email already verified")
|
||||
ErrRateLimitExceeded = errors.New("too many requests, please try again later")
|
||||
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
||||
)
|
||||
|
||||
// AuthService handles authentication business logic
|
||||
type AuthService struct {
|
||||
userRepo *repositories.UserRepository
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a token
|
||||
func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
||||
// Find user by username or email
|
||||
identifier := req.Username
|
||||
if identifier == "" {
|
||||
identifier = req.Email
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByUsernameOrEmail(identifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !user.CheckPassword(req.Password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Get or create auth token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token: %w", err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
if err := s.userRepo.UpdateLastLogin(user.ID); err != nil {
|
||||
// Log error but don't fail the login
|
||||
fmt.Printf("Failed to update last login: %v\n", err)
|
||||
}
|
||||
|
||||
return &responses.LoginResponse{
|
||||
Token: token.Key,
|
||||
User: responses.NewUserResponse(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register creates a new user account
|
||||
func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) {
|
||||
// Check if username exists
|
||||
exists, err := s.userRepo.ExistsByUsername(req.Username)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to check username: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, "", ErrUsernameTaken
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
exists, err = s.userRepo.ExistsByEmail(req.Email)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to check email: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, "", ErrEmailTaken
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// Hash password
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Save user
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Create user profile
|
||||
if _, err := s.userRepo.GetOrCreateProfile(user.ID); err != nil {
|
||||
// Log error but don't fail registration
|
||||
fmt.Printf("Failed to create user profile: %v\n", err)
|
||||
}
|
||||
|
||||
// Create auth token
|
||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create token: %w", err)
|
||||
}
|
||||
|
||||
// Generate confirmation code
|
||||
code := generateSixDigitCode()
|
||||
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
||||
|
||||
if _, err := s.userRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil {
|
||||
// Log error but don't fail registration
|
||||
fmt.Printf("Failed to create confirmation code: %v\n", err)
|
||||
}
|
||||
|
||||
return &responses.RegisterResponse{
|
||||
Token: token.Key,
|
||||
User: responses.NewUserResponse(user),
|
||||
Message: "Registration successful. Please check your email to verify your account.",
|
||||
}, code, nil
|
||||
}
|
||||
|
||||
// Logout invalidates a user's token
|
||||
func (s *AuthService) Logout(token string) error {
|
||||
return s.userRepo.DeleteToken(token)
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current authenticated user with profile
|
||||
func (s *AuthService) GetCurrentUser(userID uint) (*responses.CurrentUserResponse, error) {
|
||||
user, err := s.userRepo.FindByIDWithProfile(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := responses.NewCurrentUserResponse(user)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates a user's profile
|
||||
func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if new email is taken (if email is being changed)
|
||||
if req.Email != nil && *req.Email != user.Email {
|
||||
exists, err := s.userRepo.ExistsByEmail(*req.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check email: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
user.Email = *req.Email
|
||||
}
|
||||
|
||||
if req.FirstName != nil {
|
||||
user.FirstName = *req.FirstName
|
||||
}
|
||||
if req.LastName != nil {
|
||||
user.LastName = *req.LastName
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Reload with profile
|
||||
user, err = s.userRepo.FindByIDWithProfile(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := responses.NewCurrentUserResponse(user)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// VerifyEmail verifies a user's email with a confirmation code
|
||||
func (s *AuthService) VerifyEmail(userID uint, code string) error {
|
||||
// Get user profile
|
||||
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get profile: %w", err)
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if profile.Verified {
|
||||
return ErrAlreadyVerified
|
||||
}
|
||||
|
||||
// Check for test code in debug mode
|
||||
if s.cfg.Server.Debug && code == "123456" {
|
||||
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
||||
return fmt.Errorf("failed to verify profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find and validate confirmation code
|
||||
confirmCode, err := s.userRepo.FindConfirmationCode(userID, code)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
if errors.Is(err, repositories.ErrCodeExpired) {
|
||||
return ErrCodeExpired
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
||||
return fmt.Errorf("failed to mark code as used: %w", err)
|
||||
}
|
||||
|
||||
// Set profile as verified
|
||||
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
||||
return fmt.Errorf("failed to verify profile: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResendVerificationCode creates and returns a new verification code
|
||||
func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
|
||||
// Get user profile
|
||||
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get profile: %w", err)
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if profile.Verified {
|
||||
return "", ErrAlreadyVerified
|
||||
}
|
||||
|
||||
// Generate new code
|
||||
code := generateSixDigitCode()
|
||||
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
||||
|
||||
if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil {
|
||||
return "", fmt.Errorf("failed to create confirmation code: %w", err)
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ForgotPassword initiates the password reset process
|
||||
func (s *AuthService) ForgotPassword(email string) (string, *models.User, error) {
|
||||
// Find user by email
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) {
|
||||
// Don't reveal that the email doesn't exist
|
||||
return "", nil, nil
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
count, err := s.userRepo.CountRecentPasswordResetRequests(user.ID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to check rate limit: %w", err)
|
||||
}
|
||||
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
|
||||
return "", nil, ErrRateLimitExceeded
|
||||
}
|
||||
|
||||
// Generate code and reset token
|
||||
code := generateSixDigitCode()
|
||||
resetToken := generateResetToken()
|
||||
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
|
||||
|
||||
// Hash the code before storing
|
||||
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to hash code: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create reset code: %w", err)
|
||||
}
|
||||
|
||||
return code, user, nil
|
||||
}
|
||||
|
||||
// VerifyResetCode verifies a password reset code and returns a reset token
|
||||
func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
|
||||
// Find the reset code
|
||||
resetCode, user, err := s.userRepo.FindPasswordResetCodeByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Check for test code in debug mode
|
||||
if s.cfg.Server.Debug && code == "123456" {
|
||||
return resetCode.ResetToken, nil
|
||||
}
|
||||
|
||||
// Verify the code
|
||||
if !resetCode.CheckCode(code) {
|
||||
// Increment attempts
|
||||
s.userRepo.IncrementResetCodeAttempts(resetCode.ID)
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
|
||||
// Check if code is still valid
|
||||
if !resetCode.IsValid() {
|
||||
if resetCode.Used {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
if resetCode.Attempts >= resetCode.MaxAttempts {
|
||||
return "", ErrRateLimitExceeded
|
||||
}
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
|
||||
_ = user // user available if needed
|
||||
|
||||
return resetCode.ResetToken, nil
|
||||
}
|
||||
|
||||
// ResetPassword resets the user's password using a reset token
|
||||
func (s *AuthService) ResetPassword(resetToken, newPassword string) error {
|
||||
// Find the reset code by token
|
||||
resetCode, err := s.userRepo.FindPasswordResetCodeByToken(resetToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
|
||||
return ErrInvalidResetToken
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the user
|
||||
user, err := s.userRepo.FindByID(resetCode.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := user.SetPassword(newPassword); err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Mark reset code as used
|
||||
if err := s.userRepo.MarkPasswordResetCodeUsed(resetCode.ID); err != nil {
|
||||
// Log error but don't fail
|
||||
fmt.Printf("Failed to mark reset code as used: %v\n", err)
|
||||
}
|
||||
|
||||
// Invalidate all existing tokens for this user (security measure)
|
||||
if err := s.userRepo.DeleteTokenByUserID(user.ID); err != nil {
|
||||
// Log error but don't fail
|
||||
fmt.Printf("Failed to delete user tokens: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func generateSixDigitCode() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
if num < 0 {
|
||||
num = -num
|
||||
}
|
||||
code := num % 1000000
|
||||
return fmt.Sprintf("%06d", code)
|
||||
}
|
||||
|
||||
func generateResetToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
163
internal/services/cache_service.go
Normal file
163
internal/services/cache_service.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
)
|
||||
|
||||
// CacheService provides Redis caching functionality
|
||||
type CacheService struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
var cacheInstance *CacheService
|
||||
|
||||
// NewCacheService creates a new cache service
|
||||
func NewCacheService(cfg *config.RedisConfig) (*CacheService, error) {
|
||||
opt, err := redis.ParseURL(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Redis URL: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Password != "" {
|
||||
opt.Password = cfg.Password
|
||||
}
|
||||
if cfg.DB != 0 {
|
||||
opt.DB = cfg.DB
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
// Test connection
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("url", cfg.URL).
|
||||
Int("db", opt.DB).
|
||||
Msg("Connected to Redis")
|
||||
|
||||
cacheInstance = &CacheService{client: client}
|
||||
return cacheInstance, nil
|
||||
}
|
||||
|
||||
// GetCache returns the cache service instance
|
||||
func GetCache() *CacheService {
|
||||
return cacheInstance
|
||||
}
|
||||
|
||||
// Client returns the underlying Redis client
|
||||
func (c *CacheService) Client() *redis.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
// Set stores a value with expiration
|
||||
func (c *CacheService) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal value: %w", err)
|
||||
}
|
||||
|
||||
return c.client.Set(ctx, key, data, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a value by key
|
||||
func (c *CacheService) Get(ctx context.Context, key string, dest interface{}) error {
|
||||
data, err := c.client.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, dest)
|
||||
}
|
||||
|
||||
// GetString retrieves a string value by key
|
||||
func (c *CacheService) GetString(ctx context.Context, key string) (string, error) {
|
||||
return c.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetString stores a string value with expiration
|
||||
func (c *CacheService) SetString(ctx context.Context, key string, value string, expiration time.Duration) error {
|
||||
return c.client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Delete removes a key
|
||||
func (c *CacheService) Delete(ctx context.Context, keys ...string) error {
|
||||
return c.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Exists checks if a key exists
|
||||
func (c *CacheService) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
return c.client.Exists(ctx, keys...).Result()
|
||||
}
|
||||
|
||||
// Close closes the Redis connection
|
||||
func (c *CacheService) Close() error {
|
||||
if c.client != nil {
|
||||
return c.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Auth token cache helpers
|
||||
const (
|
||||
AuthTokenPrefix = "auth_token_"
|
||||
TokenCacheTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// CacheAuthToken caches a user ID for a token
|
||||
func (c *CacheService) CacheAuthToken(ctx context.Context, token string, userID uint) error {
|
||||
key := AuthTokenPrefix + token
|
||||
return c.SetString(ctx, key, fmt.Sprintf("%d", userID), TokenCacheTTL)
|
||||
}
|
||||
|
||||
// GetCachedAuthToken gets a cached user ID for a token
|
||||
func (c *CacheService) GetCachedAuthToken(ctx context.Context, token string) (uint, error) {
|
||||
key := AuthTokenPrefix + token
|
||||
val, err := c.GetString(ctx, key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var userID uint
|
||||
_, err = fmt.Sscanf(val, "%d", &userID)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
// InvalidateAuthToken removes a cached token
|
||||
func (c *CacheService) InvalidateAuthToken(ctx context.Context, token string) error {
|
||||
key := AuthTokenPrefix + token
|
||||
return c.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// Static data cache helpers
|
||||
const (
|
||||
StaticDataKey = "static_data"
|
||||
StaticDataTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// CacheStaticData caches static lookup data
|
||||
func (c *CacheService) CacheStaticData(ctx context.Context, data interface{}) error {
|
||||
return c.Set(ctx, StaticDataKey, data, StaticDataTTL)
|
||||
}
|
||||
|
||||
// GetCachedStaticData retrieves cached static data
|
||||
func (c *CacheService) GetCachedStaticData(ctx context.Context, dest interface{}) error {
|
||||
return c.Get(ctx, StaticDataKey, dest)
|
||||
}
|
||||
|
||||
// InvalidateStaticData removes cached static data
|
||||
func (c *CacheService) InvalidateStaticData(ctx context.Context) error {
|
||||
return c.Delete(ctx, StaticDataKey)
|
||||
}
|
||||
312
internal/services/contractor_service.go
Normal file
312
internal/services/contractor_service.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Contractor-related errors
|
||||
var (
|
||||
ErrContractorNotFound = errors.New("contractor not found")
|
||||
ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
|
||||
)
|
||||
|
||||
// ContractorService handles contractor business logic
|
||||
type ContractorService struct {
|
||||
contractorRepo *repositories.ContractorRepository
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
}
|
||||
|
||||
// NewContractorService creates a new contractor service
|
||||
func NewContractorService(contractorRepo *repositories.ContractorRepository, residenceRepo *repositories.ResidenceRepository) *ContractorService {
|
||||
return &ContractorService{
|
||||
contractorRepo: contractorRepo,
|
||||
residenceRepo: residenceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetContractor gets a contractor by ID with access check
|
||||
func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses.ContractorResponse, error) {
|
||||
contractor, err := s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrContractorNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access via residence
|
||||
hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrContractorAccessDenied
|
||||
}
|
||||
|
||||
resp := responses.NewContractorResponse(contractor)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListContractors lists all contractors accessible to a user
|
||||
func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
if len(residenceIDs) == 0 {
|
||||
return &responses.ContractorListResponse{Count: 0, Results: []responses.ContractorResponse{}}, nil
|
||||
}
|
||||
|
||||
contractors, err := s.contractorRepo.FindByUser(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewContractorListResponse(contractors)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateContractor creates a new contractor
|
||||
func (s *ContractorService) CreateContractor(req *requests.CreateContractorRequest, userID uint) (*responses.ContractorResponse, error) {
|
||||
// Check residence access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
isFavorite := false
|
||||
if req.IsFavorite != nil {
|
||||
isFavorite = *req.IsFavorite
|
||||
}
|
||||
|
||||
contractor := &models.Contractor{
|
||||
ResidenceID: req.ResidenceID,
|
||||
CreatedByID: userID,
|
||||
Name: req.Name,
|
||||
Company: req.Company,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
Website: req.Website,
|
||||
Notes: req.Notes,
|
||||
StreetAddress: req.StreetAddress,
|
||||
City: req.City,
|
||||
StateProvince: req.StateProvince,
|
||||
PostalCode: req.PostalCode,
|
||||
Rating: req.Rating,
|
||||
IsFavorite: isFavorite,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.contractorRepo.Create(contractor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set specialties if provided
|
||||
if len(req.SpecialtyIDs) > 0 {
|
||||
if err := s.contractorRepo.SetSpecialties(contractor.ID, req.SpecialtyIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
contractor, err = s.contractorRepo.FindByID(contractor.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewContractorResponse(contractor)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateContractor updates a contractor
|
||||
func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *requests.UpdateContractorRequest) (*responses.ContractorResponse, error) {
|
||||
contractor, err := s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrContractorNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrContractorAccessDenied
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if req.Name != nil {
|
||||
contractor.Name = *req.Name
|
||||
}
|
||||
if req.Company != nil {
|
||||
contractor.Company = *req.Company
|
||||
}
|
||||
if req.Phone != nil {
|
||||
contractor.Phone = *req.Phone
|
||||
}
|
||||
if req.Email != nil {
|
||||
contractor.Email = *req.Email
|
||||
}
|
||||
if req.Website != nil {
|
||||
contractor.Website = *req.Website
|
||||
}
|
||||
if req.Notes != nil {
|
||||
contractor.Notes = *req.Notes
|
||||
}
|
||||
if req.StreetAddress != nil {
|
||||
contractor.StreetAddress = *req.StreetAddress
|
||||
}
|
||||
if req.City != nil {
|
||||
contractor.City = *req.City
|
||||
}
|
||||
if req.StateProvince != nil {
|
||||
contractor.StateProvince = *req.StateProvince
|
||||
}
|
||||
if req.PostalCode != nil {
|
||||
contractor.PostalCode = *req.PostalCode
|
||||
}
|
||||
if req.Rating != nil {
|
||||
contractor.Rating = req.Rating
|
||||
}
|
||||
if req.IsFavorite != nil {
|
||||
contractor.IsFavorite = *req.IsFavorite
|
||||
}
|
||||
|
||||
if err := s.contractorRepo.Update(contractor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update specialties if provided
|
||||
if req.SpecialtyIDs != nil {
|
||||
if err := s.contractorRepo.SetSpecialties(contractorID, req.SpecialtyIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Reload
|
||||
contractor, err = s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewContractorResponse(contractor)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteContractor soft-deletes a contractor
|
||||
func (s *ContractorService) DeleteContractor(contractorID, userID uint) error {
|
||||
contractor, err := s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrContractorNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrContractorAccessDenied
|
||||
}
|
||||
|
||||
return s.contractorRepo.Delete(contractorID)
|
||||
}
|
||||
|
||||
// ToggleFavorite toggles the favorite status of a contractor
|
||||
func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*responses.ToggleFavoriteResponse, error) {
|
||||
contractor, err := s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrContractorNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrContractorAccessDenied
|
||||
}
|
||||
|
||||
newStatus, err := s.contractorRepo.ToggleFavorite(contractorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := "Contractor removed from favorites"
|
||||
if newStatus {
|
||||
message = "Contractor added to favorites"
|
||||
}
|
||||
|
||||
return &responses.ToggleFavoriteResponse{
|
||||
Message: message,
|
||||
IsFavorite: newStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetContractorTasks gets all tasks for a contractor
|
||||
func (s *ContractorService) GetContractorTasks(contractorID, userID uint) (*responses.TaskListResponse, error) {
|
||||
contractor, err := s.contractorRepo.FindByID(contractorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrContractorNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrContractorAccessDenied
|
||||
}
|
||||
|
||||
tasks, err := s.contractorRepo.GetTasksForContractor(contractorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskListResponse(tasks)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetSpecialties returns all contractor specialties
|
||||
func (s *ContractorService) GetSpecialties() ([]responses.ContractorSpecialtyResponse, error) {
|
||||
specialties, err := s.contractorRepo.GetAllSpecialties()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.ContractorSpecialtyResponse, len(specialties))
|
||||
for i, sp := range specialties {
|
||||
result[i] = responses.NewContractorSpecialtyResponse(&sp)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
313
internal/services/document_service.go
Normal file
313
internal/services/document_service.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Document-related errors
|
||||
var (
|
||||
ErrDocumentNotFound = errors.New("document not found")
|
||||
ErrDocumentAccessDenied = errors.New("you do not have access to this document")
|
||||
)
|
||||
|
||||
// DocumentService handles document business logic
|
||||
type DocumentService struct {
|
||||
documentRepo *repositories.DocumentRepository
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
}
|
||||
|
||||
// NewDocumentService creates a new document service
|
||||
func NewDocumentService(documentRepo *repositories.DocumentRepository, residenceRepo *repositories.ResidenceRepository) *DocumentService {
|
||||
return &DocumentService{
|
||||
documentRepo: documentRepo,
|
||||
residenceRepo: residenceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDocument gets a document by ID with access check
|
||||
func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.DocumentResponse, error) {
|
||||
document, err := s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access via residence
|
||||
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrDocumentAccessDenied
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentResponse(document)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListDocuments lists all documents accessible to a user
|
||||
func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
if len(residenceIDs) == 0 {
|
||||
return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil
|
||||
}
|
||||
|
||||
documents, err := s.documentRepo.FindByUser(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentListResponse(documents)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListWarranties lists all warranty documents
|
||||
func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
if len(residenceIDs) == 0 {
|
||||
return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil
|
||||
}
|
||||
|
||||
documents, err := s.documentRepo.FindWarranties(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentListResponse(documents)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateDocument creates a new document
|
||||
func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, userID uint) (*responses.DocumentResponse, error) {
|
||||
// Check residence access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
documentType := req.DocumentType
|
||||
if documentType == "" {
|
||||
documentType = models.DocumentTypeGeneral
|
||||
}
|
||||
|
||||
document := &models.Document{
|
||||
ResidenceID: req.ResidenceID,
|
||||
CreatedByID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
DocumentType: documentType,
|
||||
FileURL: req.FileURL,
|
||||
FileName: req.FileName,
|
||||
FileSize: req.FileSize,
|
||||
MimeType: req.MimeType,
|
||||
PurchaseDate: req.PurchaseDate,
|
||||
ExpiryDate: req.ExpiryDate,
|
||||
PurchasePrice: req.PurchasePrice,
|
||||
Vendor: req.Vendor,
|
||||
SerialNumber: req.SerialNumber,
|
||||
ModelNumber: req.ModelNumber,
|
||||
TaskID: req.TaskID,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.documentRepo.Create(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
document, err = s.documentRepo.FindByID(document.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentResponse(document)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateDocument updates a document
|
||||
func (s *DocumentService) UpdateDocument(documentID, userID uint, req *requests.UpdateDocumentRequest) (*responses.DocumentResponse, error) {
|
||||
document, err := s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrDocumentAccessDenied
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if req.Title != nil {
|
||||
document.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
document.Description = *req.Description
|
||||
}
|
||||
if req.DocumentType != nil {
|
||||
document.DocumentType = *req.DocumentType
|
||||
}
|
||||
if req.FileURL != nil {
|
||||
document.FileURL = *req.FileURL
|
||||
}
|
||||
if req.FileName != nil {
|
||||
document.FileName = *req.FileName
|
||||
}
|
||||
if req.FileSize != nil {
|
||||
document.FileSize = req.FileSize
|
||||
}
|
||||
if req.MimeType != nil {
|
||||
document.MimeType = *req.MimeType
|
||||
}
|
||||
if req.PurchaseDate != nil {
|
||||
document.PurchaseDate = req.PurchaseDate
|
||||
}
|
||||
if req.ExpiryDate != nil {
|
||||
document.ExpiryDate = req.ExpiryDate
|
||||
}
|
||||
if req.PurchasePrice != nil {
|
||||
document.PurchasePrice = req.PurchasePrice
|
||||
}
|
||||
if req.Vendor != nil {
|
||||
document.Vendor = *req.Vendor
|
||||
}
|
||||
if req.SerialNumber != nil {
|
||||
document.SerialNumber = *req.SerialNumber
|
||||
}
|
||||
if req.ModelNumber != nil {
|
||||
document.ModelNumber = *req.ModelNumber
|
||||
}
|
||||
if req.TaskID != nil {
|
||||
document.TaskID = req.TaskID
|
||||
}
|
||||
|
||||
if err := s.documentRepo.Update(document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
document, err = s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentResponse(document)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteDocument soft-deletes a document
|
||||
func (s *DocumentService) DeleteDocument(documentID, userID uint) error {
|
||||
document, err := s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrDocumentNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrDocumentAccessDenied
|
||||
}
|
||||
|
||||
return s.documentRepo.Delete(documentID)
|
||||
}
|
||||
|
||||
// ActivateDocument activates a document
|
||||
func (s *DocumentService) ActivateDocument(documentID, userID uint) (*responses.DocumentResponse, error) {
|
||||
// First check if document exists (even if inactive)
|
||||
var document models.Document
|
||||
if err := s.documentRepo.FindByIDIncludingInactive(documentID, &document); err != nil {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrDocumentAccessDenied
|
||||
}
|
||||
|
||||
if err := s.documentRepo.Activate(documentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
doc, err := s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewDocumentResponse(doc)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeactivateDocument deactivates a document
|
||||
func (s *DocumentService) DeactivateDocument(documentID, userID uint) (*responses.DocumentResponse, error) {
|
||||
document, err := s.documentRepo.FindByID(documentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrDocumentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrDocumentAccessDenied
|
||||
}
|
||||
|
||||
if err := s.documentRepo.Deactivate(documentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
document.IsActive = false
|
||||
resp := responses.NewDocumentResponse(document)
|
||||
return &resp, nil
|
||||
}
|
||||
305
internal/services/email_service.go
Normal file
305
internal/services/email_service.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails
|
||||
type EmailService struct {
|
||||
cfg *config.EmailConfig
|
||||
dialer *gomail.Dialer
|
||||
}
|
||||
|
||||
// NewEmailService creates a new email service
|
||||
func NewEmailService(cfg *config.EmailConfig) *EmailService {
|
||||
dialer := gomail.NewDialer(cfg.Host, cfg.Port, cfg.User, cfg.Password)
|
||||
|
||||
return &EmailService{
|
||||
cfg: cfg,
|
||||
dialer: dialer,
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmail sends an email
|
||||
func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.cfg.From)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", textBody)
|
||||
m.AddAlternative("text/html", htmlBody)
|
||||
|
||||
if err := s.dialer.DialAndSend(m); err != nil {
|
||||
log.Error().Err(err).Str("to", to).Str("subject", subject).Msg("Failed to send email")
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Str("to", to).Str("subject", subject).Msg("Email sent successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendWelcomeEmail sends a welcome email with verification code
|
||||
func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error {
|
||||
subject := "Welcome to MyCrib - Verify Your Email"
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "there"
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { text-align: center; padding: 20px 0; }
|
||||
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Welcome to MyCrib!</h1>
|
||||
</div>
|
||||
<p>Hi %s,</p>
|
||||
<p>Thank you for creating a MyCrib account. To complete your registration, please verify your email address by entering the following code:</p>
|
||||
<div class="code">%s</div>
|
||||
<p>This code will expire in 24 hours.</p>
|
||||
<p>If you didn't create a MyCrib account, you can safely ignore this email.</p>
|
||||
<p>Best regards,<br>The MyCrib Team</p>
|
||||
<div class="footer">
|
||||
<p>© %d MyCrib. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, name, code, time.Now().Year())
|
||||
|
||||
textBody := fmt.Sprintf(`
|
||||
Welcome to MyCrib!
|
||||
|
||||
Hi %s,
|
||||
|
||||
Thank you for creating a MyCrib account. To complete your registration, please verify your email address by entering the following code:
|
||||
|
||||
%s
|
||||
|
||||
This code will expire in 24 hours.
|
||||
|
||||
If you didn't create a MyCrib account, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
The MyCrib Team
|
||||
`, name, code)
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// SendVerificationEmail sends an email verification code
|
||||
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
|
||||
subject := "MyCrib - Verify Your Email"
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "there"
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Verify Your Email</h1>
|
||||
<p>Hi %s,</p>
|
||||
<p>Please use the following code to verify your email address:</p>
|
||||
<div class="code">%s</div>
|
||||
<p>This code will expire in 24 hours.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
<p>Best regards,<br>The MyCrib Team</p>
|
||||
<div class="footer">
|
||||
<p>© %d MyCrib. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, name, code, time.Now().Year())
|
||||
|
||||
textBody := fmt.Sprintf(`
|
||||
Verify Your Email
|
||||
|
||||
Hi %s,
|
||||
|
||||
Please use the following code to verify your email address:
|
||||
|
||||
%s
|
||||
|
||||
This code will expire in 24 hours.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
The MyCrib Team
|
||||
`, name, code)
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail sends a password reset email
|
||||
func (s *EmailService) SendPasswordResetEmail(to, firstName, code string) error {
|
||||
subject := "MyCrib - Password Reset Request"
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "there"
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
|
||||
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi %s,</p>
|
||||
<p>We received a request to reset your password. Use the following code to complete the reset:</p>
|
||||
<div class="code">%s</div>
|
||||
<p>This code will expire in 15 minutes.</p>
|
||||
<div class="warning">
|
||||
<strong>Security Notice:</strong> If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
</div>
|
||||
<p>Best regards,<br>The MyCrib Team</p>
|
||||
<div class="footer">
|
||||
<p>© %d MyCrib. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, name, code, time.Now().Year())
|
||||
|
||||
textBody := fmt.Sprintf(`
|
||||
Password Reset Request
|
||||
|
||||
Hi %s,
|
||||
|
||||
We received a request to reset your password. Use the following code to complete the reset:
|
||||
|
||||
%s
|
||||
|
||||
This code will expire in 15 minutes.
|
||||
|
||||
SECURITY NOTICE: If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
|
||||
|
||||
Best regards,
|
||||
The MyCrib Team
|
||||
`, name, code)
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// SendPasswordChangedEmail sends a password changed confirmation email
|
||||
func (s *EmailService) SendPasswordChangedEmail(to, firstName string) error {
|
||||
subject := "MyCrib - Your Password Has Been Changed"
|
||||
|
||||
name := firstName
|
||||
if name == "" {
|
||||
name = "there"
|
||||
}
|
||||
|
||||
htmlBody := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Password Changed</h1>
|
||||
<p>Hi %s,</p>
|
||||
<p>Your MyCrib password was successfully changed on %s.</p>
|
||||
<div class="warning">
|
||||
<strong>Didn't make this change?</strong> If you didn't change your password, please contact us immediately at support@mycrib.com or reset your password.
|
||||
</div>
|
||||
<p>Best regards,<br>The MyCrib Team</p>
|
||||
<div class="footer">
|
||||
<p>© %d MyCrib. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC"), time.Now().Year())
|
||||
|
||||
textBody := fmt.Sprintf(`
|
||||
Password Changed
|
||||
|
||||
Hi %s,
|
||||
|
||||
Your MyCrib password was successfully changed on %s.
|
||||
|
||||
DIDN'T MAKE THIS CHANGE? If you didn't change your password, please contact us immediately at support@mycrib.com or reset your password.
|
||||
|
||||
Best regards,
|
||||
The MyCrib Team
|
||||
`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC"))
|
||||
|
||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
||||
}
|
||||
|
||||
// EmailTemplate represents an email template
|
||||
type EmailTemplate struct {
|
||||
name string
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// ParseTemplate parses an email template from a string
|
||||
func ParseTemplate(name, tmpl string) (*EmailTemplate, error) {
|
||||
t, err := template.New(name).Parse(tmpl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EmailTemplate{name: name, template: t}, nil
|
||||
}
|
||||
|
||||
// Execute executes the template with the given data
|
||||
func (t *EmailTemplate) Execute(data interface{}) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := t.template.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
428
internal/services/notification_service.go
Normal file
428
internal/services/notification_service.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/push"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Notification-related errors
|
||||
var (
|
||||
ErrNotificationNotFound = errors.New("notification not found")
|
||||
ErrDeviceNotFound = errors.New("device not found")
|
||||
ErrInvalidPlatform = errors.New("invalid platform, must be 'ios' or 'android'")
|
||||
)
|
||||
|
||||
// NotificationService handles notification business logic
|
||||
type NotificationService struct {
|
||||
notificationRepo *repositories.NotificationRepository
|
||||
gorushClient *push.GorushClient
|
||||
}
|
||||
|
||||
// NewNotificationService creates a new notification service
|
||||
func NewNotificationService(notificationRepo *repositories.NotificationRepository, gorushClient *push.GorushClient) *NotificationService {
|
||||
return &NotificationService{
|
||||
notificationRepo: notificationRepo,
|
||||
gorushClient: gorushClient,
|
||||
}
|
||||
}
|
||||
|
||||
// === Notifications ===
|
||||
|
||||
// GetNotifications gets notifications for a user
|
||||
func (s *NotificationService) GetNotifications(userID uint, limit, offset int) ([]NotificationResponse, error) {
|
||||
notifications, err := s.notificationRepo.FindByUser(userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]NotificationResponse, len(notifications))
|
||||
for i, n := range notifications {
|
||||
result[i] = NewNotificationResponse(&n)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUnreadCount gets the count of unread notifications
|
||||
func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
|
||||
return s.notificationRepo.CountUnread(userID)
|
||||
}
|
||||
|
||||
// MarkAsRead marks a notification as read
|
||||
func (s *NotificationService) MarkAsRead(notificationID, userID uint) error {
|
||||
notification, err := s.notificationRepo.FindByID(notificationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if notification.UserID != userID {
|
||||
return ErrNotificationNotFound
|
||||
}
|
||||
|
||||
return s.notificationRepo.MarkAsRead(notificationID)
|
||||
}
|
||||
|
||||
// MarkAllAsRead marks all notifications as read
|
||||
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
||||
return s.notificationRepo.MarkAllAsRead(userID)
|
||||
}
|
||||
|
||||
// CreateAndSendNotification creates a notification and sends it via push
|
||||
func (s *NotificationService) CreateAndSendNotification(ctx context.Context, userID uint, notificationType models.NotificationType, title, body string, data map[string]interface{}) error {
|
||||
// Check user preferences
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if notification type is enabled
|
||||
if !s.isNotificationEnabled(prefs, notificationType) {
|
||||
return nil // Skip silently
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
notification := &models.Notification{
|
||||
UserID: userID,
|
||||
NotificationType: notificationType,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Data: string(dataJSON),
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.Create(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get device tokens
|
||||
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert data for push
|
||||
pushData := make(map[string]string)
|
||||
for k, v := range data {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
pushData[k] = val
|
||||
default:
|
||||
jsonVal, _ := json.Marshal(val)
|
||||
pushData[k] = string(jsonVal)
|
||||
}
|
||||
}
|
||||
pushData["notification_id"] = string(rune(notification.ID))
|
||||
|
||||
// Send push notification
|
||||
if s.gorushClient != nil {
|
||||
err = s.gorushClient.SendToAll(ctx, iosTokens, androidTokens, title, body, pushData)
|
||||
if err != nil {
|
||||
s.notificationRepo.SetError(notification.ID, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.notificationRepo.MarkAsSent(notification.ID)
|
||||
}
|
||||
|
||||
// isNotificationEnabled checks if a notification type is enabled for user
|
||||
func (s *NotificationService) isNotificationEnabled(prefs *models.NotificationPreference, notificationType models.NotificationType) bool {
|
||||
switch notificationType {
|
||||
case models.NotificationTaskDueSoon:
|
||||
return prefs.TaskDueSoon
|
||||
case models.NotificationTaskOverdue:
|
||||
return prefs.TaskOverdue
|
||||
case models.NotificationTaskCompleted:
|
||||
return prefs.TaskCompleted
|
||||
case models.NotificationTaskAssigned:
|
||||
return prefs.TaskAssigned
|
||||
case models.NotificationResidenceShared:
|
||||
return prefs.ResidenceShared
|
||||
case models.NotificationWarrantyExpiring:
|
||||
return prefs.WarrantyExpiring
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// === Notification Preferences ===
|
||||
|
||||
// GetPreferences gets notification preferences for a user
|
||||
func (s *NotificationService) GetPreferences(userID uint) (*NotificationPreferencesResponse, error) {
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewNotificationPreferencesResponse(prefs), nil
|
||||
}
|
||||
|
||||
// UpdatePreferences updates notification preferences
|
||||
func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) {
|
||||
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.TaskDueSoon != nil {
|
||||
prefs.TaskDueSoon = *req.TaskDueSoon
|
||||
}
|
||||
if req.TaskOverdue != nil {
|
||||
prefs.TaskOverdue = *req.TaskOverdue
|
||||
}
|
||||
if req.TaskCompleted != nil {
|
||||
prefs.TaskCompleted = *req.TaskCompleted
|
||||
}
|
||||
if req.TaskAssigned != nil {
|
||||
prefs.TaskAssigned = *req.TaskAssigned
|
||||
}
|
||||
if req.ResidenceShared != nil {
|
||||
prefs.ResidenceShared = *req.ResidenceShared
|
||||
}
|
||||
if req.WarrantyExpiring != nil {
|
||||
prefs.WarrantyExpiring = *req.WarrantyExpiring
|
||||
}
|
||||
|
||||
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewNotificationPreferencesResponse(prefs), nil
|
||||
}
|
||||
|
||||
// === Device Registration ===
|
||||
|
||||
// RegisterDevice registers a device for push notifications
|
||||
func (s *NotificationService) RegisterDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
switch req.Platform {
|
||||
case push.PlatformIOS:
|
||||
return s.registerAPNSDevice(userID, req)
|
||||
case push.PlatformAndroid:
|
||||
return s.registerGCMDevice(userID, req)
|
||||
default:
|
||||
return nil, ErrInvalidPlatform
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.FindAPNSDeviceByToken(req.RegistrationID)
|
||||
if err == nil {
|
||||
// Update existing device
|
||||
existing.UserID = &userID
|
||||
existing.Active = true
|
||||
existing.Name = req.Name
|
||||
existing.DeviceID = req.DeviceID
|
||||
if err := s.notificationRepo.UpdateAPNSDevice(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewAPNSDeviceResponse(existing), nil
|
||||
}
|
||||
|
||||
// Create new device
|
||||
device := &models.APNSDevice{
|
||||
UserID: &userID,
|
||||
Name: req.Name,
|
||||
DeviceID: req.DeviceID,
|
||||
RegistrationID: req.RegistrationID,
|
||||
Active: true,
|
||||
}
|
||||
if err := s.notificationRepo.CreateAPNSDevice(device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewAPNSDeviceResponse(device), nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
|
||||
// Check if device exists
|
||||
existing, err := s.notificationRepo.FindGCMDeviceByToken(req.RegistrationID)
|
||||
if err == nil {
|
||||
// Update existing device
|
||||
existing.UserID = &userID
|
||||
existing.Active = true
|
||||
existing.Name = req.Name
|
||||
existing.DeviceID = req.DeviceID
|
||||
if err := s.notificationRepo.UpdateGCMDevice(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewGCMDeviceResponse(existing), nil
|
||||
}
|
||||
|
||||
// Create new device
|
||||
device := &models.GCMDevice{
|
||||
UserID: &userID,
|
||||
Name: req.Name,
|
||||
DeviceID: req.DeviceID,
|
||||
RegistrationID: req.RegistrationID,
|
||||
CloudMessageType: "FCM",
|
||||
Active: true,
|
||||
}
|
||||
if err := s.notificationRepo.CreateGCMDevice(device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewGCMDeviceResponse(device), nil
|
||||
}
|
||||
|
||||
// ListDevices lists all devices for a user
|
||||
func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error) {
|
||||
iosDevices, err := s.notificationRepo.FindAPNSDevicesByUser(userID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
androidDevices, err := s.notificationRepo.FindGCMDevicesByUser(userID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]DeviceResponse, 0, len(iosDevices)+len(androidDevices))
|
||||
for _, d := range iosDevices {
|
||||
result = append(result, *NewAPNSDeviceResponse(&d))
|
||||
}
|
||||
for _, d := range androidDevices {
|
||||
result = append(result, *NewGCMDeviceResponse(&d))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteDevice deletes a device
|
||||
func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userID uint) error {
|
||||
switch platform {
|
||||
case push.PlatformIOS:
|
||||
return s.notificationRepo.DeactivateAPNSDevice(deviceID)
|
||||
case push.PlatformAndroid:
|
||||
return s.notificationRepo.DeactivateGCMDevice(deviceID)
|
||||
default:
|
||||
return ErrInvalidPlatform
|
||||
}
|
||||
}
|
||||
|
||||
// === Response/Request Types ===
|
||||
|
||||
// NotificationResponse represents a notification in API response
|
||||
type NotificationResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
NotificationType models.NotificationType `json:"notification_type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Read bool `json:"read"`
|
||||
ReadAt *string `json:"read_at"`
|
||||
Sent bool `json:"sent"`
|
||||
SentAt *string `json:"sent_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewNotificationResponse creates a NotificationResponse
|
||||
func NewNotificationResponse(n *models.Notification) NotificationResponse {
|
||||
resp := NotificationResponse{
|
||||
ID: n.ID,
|
||||
UserID: n.UserID,
|
||||
NotificationType: n.NotificationType,
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
Read: n.Read,
|
||||
Sent: n.Sent,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if n.Data != "" {
|
||||
json.Unmarshal([]byte(n.Data), &resp.Data)
|
||||
}
|
||||
if n.ReadAt != nil {
|
||||
t := n.ReadAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.ReadAt = &t
|
||||
}
|
||||
if n.SentAt != nil {
|
||||
t := n.SentAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.SentAt = &t
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// NotificationPreferencesResponse represents notification preferences
|
||||
type NotificationPreferencesResponse struct {
|
||||
TaskDueSoon bool `json:"task_due_soon"`
|
||||
TaskOverdue bool `json:"task_overdue"`
|
||||
TaskCompleted bool `json:"task_completed"`
|
||||
TaskAssigned bool `json:"task_assigned"`
|
||||
ResidenceShared bool `json:"residence_shared"`
|
||||
WarrantyExpiring bool `json:"warranty_expiring"`
|
||||
}
|
||||
|
||||
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
|
||||
func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse {
|
||||
return &NotificationPreferencesResponse{
|
||||
TaskDueSoon: p.TaskDueSoon,
|
||||
TaskOverdue: p.TaskOverdue,
|
||||
TaskCompleted: p.TaskCompleted,
|
||||
TaskAssigned: p.TaskAssigned,
|
||||
ResidenceShared: p.ResidenceShared,
|
||||
WarrantyExpiring: p.WarrantyExpiring,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePreferencesRequest represents preferences update request
|
||||
type UpdatePreferencesRequest struct {
|
||||
TaskDueSoon *bool `json:"task_due_soon"`
|
||||
TaskOverdue *bool `json:"task_overdue"`
|
||||
TaskCompleted *bool `json:"task_completed"`
|
||||
TaskAssigned *bool `json:"task_assigned"`
|
||||
ResidenceShared *bool `json:"residence_shared"`
|
||||
WarrantyExpiring *bool `json:"warranty_expiring"`
|
||||
}
|
||||
|
||||
// DeviceResponse represents a device in API response
|
||||
type DeviceResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
RegistrationID string `json:"registration_id"`
|
||||
Platform string `json:"platform"`
|
||||
Active bool `json:"active"`
|
||||
DateCreated string `json:"date_created"`
|
||||
}
|
||||
|
||||
// NewAPNSDeviceResponse creates a DeviceResponse from APNS device
|
||||
func NewAPNSDeviceResponse(d *models.APNSDevice) *DeviceResponse {
|
||||
return &DeviceResponse{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
DeviceID: d.DeviceID,
|
||||
RegistrationID: d.RegistrationID,
|
||||
Platform: push.PlatformIOS,
|
||||
Active: d.Active,
|
||||
DateCreated: d.DateCreated.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewGCMDeviceResponse creates a DeviceResponse from GCM device
|
||||
func NewGCMDeviceResponse(d *models.GCMDevice) *DeviceResponse {
|
||||
return &DeviceResponse{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
DeviceID: d.DeviceID,
|
||||
RegistrationID: d.RegistrationID,
|
||||
Platform: push.PlatformAndroid,
|
||||
Active: d.Active,
|
||||
DateCreated: d.DateCreated.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterDeviceRequest represents device registration request
|
||||
type RegisterDeviceRequest struct {
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
RegistrationID string `json:"registration_id" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
381
internal/services/residence_service.go
Normal file
381
internal/services/residence_service.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrResidenceNotFound = errors.New("residence not found")
|
||||
ErrResidenceAccessDenied = errors.New("you do not have access to this residence")
|
||||
ErrNotResidenceOwner = errors.New("only the residence owner can perform this action")
|
||||
ErrCannotRemoveOwner = errors.New("cannot remove the owner from the residence")
|
||||
ErrUserAlreadyMember = errors.New("user is already a member of this residence")
|
||||
ErrShareCodeInvalid = errors.New("invalid or expired share code")
|
||||
ErrShareCodeExpired = errors.New("share code has expired")
|
||||
ErrPropertiesLimitReached = errors.New("you have reached the maximum number of properties for your subscription tier")
|
||||
)
|
||||
|
||||
// ResidenceService handles residence business logic
|
||||
type ResidenceService struct {
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
userRepo *repositories.UserRepository
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewResidenceService creates a new residence service
|
||||
func NewResidenceService(residenceRepo *repositories.ResidenceRepository, userRepo *repositories.UserRepository, cfg *config.Config) *ResidenceService {
|
||||
return &ResidenceService{
|
||||
residenceRepo: residenceRepo,
|
||||
userRepo: userRepo,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// GetResidence gets a residence by ID with access check
|
||||
func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.ResidenceResponse, error) {
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
residence, err := s.residenceRepo.FindByID(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrResidenceNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceResponse(residence)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListResidences lists all residences accessible to a user
|
||||
func (s *ResidenceService) ListResidences(userID uint) (*responses.ResidenceListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceListResponse(residences)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetMyResidences returns residences with additional details (tasks, completions, etc.)
|
||||
// This is the "my-residences" endpoint that returns richer data
|
||||
func (s *ResidenceService) GetMyResidences(userID uint) (*responses.ResidenceListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: In Phase 4, this will include tasks and completions
|
||||
resp := responses.NewResidenceListResponse(residences)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateResidence creates a new residence
|
||||
func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceResponse, error) {
|
||||
// TODO: Check subscription tier limits
|
||||
// count, err := s.residenceRepo.CountByOwner(ownerID)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// Check against tier limits...
|
||||
|
||||
isPrimary := true
|
||||
if req.IsPrimary != nil {
|
||||
isPrimary = *req.IsPrimary
|
||||
}
|
||||
|
||||
// Set default country if not provided
|
||||
country := req.Country
|
||||
if country == "" {
|
||||
country = "USA"
|
||||
}
|
||||
|
||||
residence := &models.Residence{
|
||||
OwnerID: ownerID,
|
||||
Name: req.Name,
|
||||
PropertyTypeID: req.PropertyTypeID,
|
||||
StreetAddress: req.StreetAddress,
|
||||
ApartmentUnit: req.ApartmentUnit,
|
||||
City: req.City,
|
||||
StateProvince: req.StateProvince,
|
||||
PostalCode: req.PostalCode,
|
||||
Country: country,
|
||||
Bedrooms: req.Bedrooms,
|
||||
Bathrooms: req.Bathrooms,
|
||||
SquareFootage: req.SquareFootage,
|
||||
LotSize: req.LotSize,
|
||||
YearBuilt: req.YearBuilt,
|
||||
Description: req.Description,
|
||||
PurchaseDate: req.PurchaseDate,
|
||||
PurchasePrice: req.PurchasePrice,
|
||||
IsPrimary: isPrimary,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.residenceRepo.Create(residence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
residence, err := s.residenceRepo.FindByID(residence.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceResponse(residence)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateResidence updates a residence
|
||||
func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceResponse, error) {
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
}
|
||||
|
||||
residence, err := s.residenceRepo.FindByID(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrResidenceNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply updates (only non-nil fields)
|
||||
if req.Name != nil {
|
||||
residence.Name = *req.Name
|
||||
}
|
||||
if req.PropertyTypeID != nil {
|
||||
residence.PropertyTypeID = req.PropertyTypeID
|
||||
}
|
||||
if req.StreetAddress != nil {
|
||||
residence.StreetAddress = *req.StreetAddress
|
||||
}
|
||||
if req.ApartmentUnit != nil {
|
||||
residence.ApartmentUnit = *req.ApartmentUnit
|
||||
}
|
||||
if req.City != nil {
|
||||
residence.City = *req.City
|
||||
}
|
||||
if req.StateProvince != nil {
|
||||
residence.StateProvince = *req.StateProvince
|
||||
}
|
||||
if req.PostalCode != nil {
|
||||
residence.PostalCode = *req.PostalCode
|
||||
}
|
||||
if req.Country != nil {
|
||||
residence.Country = *req.Country
|
||||
}
|
||||
if req.Bedrooms != nil {
|
||||
residence.Bedrooms = req.Bedrooms
|
||||
}
|
||||
if req.Bathrooms != nil {
|
||||
residence.Bathrooms = req.Bathrooms
|
||||
}
|
||||
if req.SquareFootage != nil {
|
||||
residence.SquareFootage = req.SquareFootage
|
||||
}
|
||||
if req.LotSize != nil {
|
||||
residence.LotSize = req.LotSize
|
||||
}
|
||||
if req.YearBuilt != nil {
|
||||
residence.YearBuilt = req.YearBuilt
|
||||
}
|
||||
if req.Description != nil {
|
||||
residence.Description = *req.Description
|
||||
}
|
||||
if req.PurchaseDate != nil {
|
||||
residence.PurchaseDate = req.PurchaseDate
|
||||
}
|
||||
if req.PurchasePrice != nil {
|
||||
residence.PurchasePrice = req.PurchasePrice
|
||||
}
|
||||
if req.IsPrimary != nil {
|
||||
residence.IsPrimary = *req.IsPrimary
|
||||
}
|
||||
|
||||
if err := s.residenceRepo.Update(residence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
residence, err = s.residenceRepo.FindByID(residence.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceResponse(residence)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteResidence soft-deletes a residence
|
||||
func (s *ResidenceService) DeleteResidence(residenceID, userID uint) error {
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isOwner {
|
||||
return ErrNotResidenceOwner
|
||||
}
|
||||
|
||||
return s.residenceRepo.Delete(residenceID)
|
||||
}
|
||||
|
||||
// GenerateShareCode generates a new share code for a residence
|
||||
func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresInHours int) (*responses.GenerateShareCodeResponse, error) {
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
}
|
||||
|
||||
// Default to 24 hours if not specified
|
||||
if expiresInHours <= 0 {
|
||||
expiresInHours = 24
|
||||
}
|
||||
|
||||
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &responses.GenerateShareCodeResponse{
|
||||
Message: "Share code generated successfully",
|
||||
ShareCode: responses.NewShareCodeResponse(shareCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JoinWithCode allows a user to join a residence using a share code
|
||||
func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.JoinResidenceResponse, error) {
|
||||
// Find the share code
|
||||
shareCode, err := s.residenceRepo.FindShareCodeByCode(code)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrShareCodeInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasAccess {
|
||||
return nil, ErrUserAlreadyMember
|
||||
}
|
||||
|
||||
// Add user to residence
|
||||
if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the residence with full details
|
||||
residence, err := s.residenceRepo.FindByID(shareCode.ResidenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &responses.JoinResidenceResponse{
|
||||
Message: "Successfully joined residence",
|
||||
Residence: responses.NewResidenceResponse(residence),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetResidenceUsers returns all users with access to a residence
|
||||
func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]responses.ResidenceUserResponse, error) {
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
users, err := s.residenceRepo.GetResidenceUsers(residenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.ResidenceUserResponse, len(users))
|
||||
for i, user := range users {
|
||||
result[i] = *responses.NewResidenceUserResponse(&user)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RemoveUser removes a user from a residence (owner only)
|
||||
func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUserID uint) error {
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, requestingUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isOwner {
|
||||
return ErrNotResidenceOwner
|
||||
}
|
||||
|
||||
// Cannot remove the owner
|
||||
if userIDToRemove == requestingUserID {
|
||||
return ErrCannotRemoveOwner
|
||||
}
|
||||
|
||||
// Check if the residence exists
|
||||
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrResidenceNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Cannot remove the owner
|
||||
if userIDToRemove == residence.OwnerID {
|
||||
return ErrCannotRemoveOwner
|
||||
}
|
||||
|
||||
return s.residenceRepo.RemoveUser(residenceID, userIDToRemove)
|
||||
}
|
||||
|
||||
// GetResidenceTypes returns all residence types
|
||||
func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) {
|
||||
types, err := s.residenceRepo.GetAllResidenceTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.ResidenceTypeResponse, len(types))
|
||||
for i, t := range types {
|
||||
result[i] = *responses.NewResidenceTypeResponse(&t)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
417
internal/services/subscription_service.go
Normal file
417
internal/services/subscription_service.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Subscription-related errors
|
||||
var (
|
||||
ErrSubscriptionNotFound = errors.New("subscription not found")
|
||||
ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier")
|
||||
ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier")
|
||||
ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier")
|
||||
ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier")
|
||||
ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found")
|
||||
ErrPromotionNotFound = errors.New("promotion not found")
|
||||
)
|
||||
|
||||
// SubscriptionService handles subscription business logic
|
||||
type SubscriptionService struct {
|
||||
subscriptionRepo *repositories.SubscriptionRepository
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
taskRepo *repositories.TaskRepository
|
||||
contractorRepo *repositories.ContractorRepository
|
||||
documentRepo *repositories.DocumentRepository
|
||||
}
|
||||
|
||||
// NewSubscriptionService creates a new subscription service
|
||||
func NewSubscriptionService(
|
||||
subscriptionRepo *repositories.SubscriptionRepository,
|
||||
residenceRepo *repositories.ResidenceRepository,
|
||||
taskRepo *repositories.TaskRepository,
|
||||
contractorRepo *repositories.ContractorRepository,
|
||||
documentRepo *repositories.DocumentRepository,
|
||||
) *SubscriptionService {
|
||||
return &SubscriptionService{
|
||||
subscriptionRepo: subscriptionRepo,
|
||||
residenceRepo: residenceRepo,
|
||||
taskRepo: taskRepo,
|
||||
contractorRepo: contractorRepo,
|
||||
documentRepo: documentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscription gets the subscription for a user
|
||||
func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSubscriptionResponse(sub), nil
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus gets detailed subscription status including limits
|
||||
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, err := s.subscriptionRepo.GetSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current usage if limitations are enabled
|
||||
var usage *UsageResponse
|
||||
if settings.EnableLimitations {
|
||||
usage, err = s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &SubscriptionStatusResponse{
|
||||
Subscription: NewSubscriptionResponse(sub),
|
||||
Limits: NewTierLimitsResponse(limits),
|
||||
Usage: usage,
|
||||
LimitationsEnabled: settings.EnableLimitations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getUserUsage calculates current usage for a user
|
||||
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
|
||||
residences, err := s.residenceRepo.FindOwnedByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
propertiesCount := int64(len(residences))
|
||||
|
||||
// Count tasks, contractors, and documents across all user's residences
|
||||
var tasksCount, contractorsCount, documentsCount int64
|
||||
for _, r := range residences {
|
||||
tc, err := s.taskRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasksCount += tc
|
||||
|
||||
cc, err := s.contractorRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contractorsCount += cc
|
||||
|
||||
dc, err := s.documentRepo.CountByResidence(r.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
documentsCount += dc
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
Properties: propertiesCount,
|
||||
Tasks: tasksCount,
|
||||
Contractors: contractorsCount,
|
||||
Documents: documentsCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckLimit checks if a user has exceeded a specific limit
|
||||
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
||||
settings, err := s.subscriptionRepo.GetSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If limitations are disabled, allow everything
|
||||
if !settings.EnableLimitations {
|
||||
return nil
|
||||
}
|
||||
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pro users have unlimited access
|
||||
if sub.IsPro() {
|
||||
return nil
|
||||
}
|
||||
|
||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usage, err := s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch limitType {
|
||||
case "properties":
|
||||
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) {
|
||||
return ErrPropertiesLimitExceeded
|
||||
}
|
||||
case "tasks":
|
||||
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) {
|
||||
return ErrTasksLimitExceeded
|
||||
}
|
||||
case "contractors":
|
||||
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) {
|
||||
return ErrContractorsLimitExceeded
|
||||
}
|
||||
case "documents":
|
||||
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) {
|
||||
return ErrDocumentsLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpgradeTrigger gets an upgrade trigger by key
|
||||
func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResponse, error) {
|
||||
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrUpgradeTriggerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return NewUpgradeTriggerResponse(trigger), nil
|
||||
}
|
||||
|
||||
// GetFeatureBenefits gets all feature benefits
|
||||
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
||||
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]FeatureBenefitResponse, len(benefits))
|
||||
for i, b := range benefits {
|
||||
result[i] = *NewFeatureBenefitResponse(&b)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetActivePromotions gets active promotions for a user
|
||||
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
|
||||
sub, err := s.subscriptionRepo.GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]PromotionResponse, len(promotions))
|
||||
for i, p := range promotions {
|
||||
result[i] = *NewPromotionResponse(&p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProcessApplePurchase processes an Apple IAP purchase
|
||||
func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData string) (*SubscriptionResponse, error) {
|
||||
// TODO: Implement receipt validation with Apple's servers
|
||||
// For now, just upgrade the user
|
||||
|
||||
// Store receipt data
|
||||
if err := s.subscriptionRepo.UpdateReceiptData(userID, receiptData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Upgrade to Pro (1 year from now - adjust based on actual subscription)
|
||||
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// ProcessGooglePurchase processes a Google Play purchase
|
||||
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string) (*SubscriptionResponse, error) {
|
||||
// TODO: Implement token validation with Google's servers
|
||||
// For now, just upgrade the user
|
||||
|
||||
// Store purchase token
|
||||
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Upgrade to Pro (1 year from now - adjust based on actual subscription)
|
||||
expiresAt := time.Now().UTC().AddDate(1, 0, 0)
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// CancelSubscription cancels a subscription (downgrades to free at end of period)
|
||||
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
|
||||
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSubscription(userID)
|
||||
}
|
||||
|
||||
// === Response Types ===
|
||||
|
||||
// SubscriptionResponse represents a subscription in API response
|
||||
type SubscriptionResponse struct {
|
||||
Tier string `json:"tier"`
|
||||
SubscribedAt *string `json:"subscribed_at"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
CancelledAt *string `json:"cancelled_at"`
|
||||
Platform string `json:"platform"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsPro bool `json:"is_pro"`
|
||||
}
|
||||
|
||||
// NewSubscriptionResponse creates a SubscriptionResponse from a model
|
||||
func NewSubscriptionResponse(s *models.UserSubscription) *SubscriptionResponse {
|
||||
resp := &SubscriptionResponse{
|
||||
Tier: string(s.Tier),
|
||||
AutoRenew: s.AutoRenew,
|
||||
Platform: s.Platform,
|
||||
IsActive: s.IsActive(),
|
||||
IsPro: s.IsPro(),
|
||||
}
|
||||
if s.SubscribedAt != nil {
|
||||
t := s.SubscribedAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.SubscribedAt = &t
|
||||
}
|
||||
if s.ExpiresAt != nil {
|
||||
t := s.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.ExpiresAt = &t
|
||||
}
|
||||
if s.CancelledAt != nil {
|
||||
t := s.CancelledAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.CancelledAt = &t
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TierLimitsResponse represents tier limits
|
||||
type TierLimitsResponse struct {
|
||||
Tier string `json:"tier"`
|
||||
PropertiesLimit *int `json:"properties_limit"`
|
||||
TasksLimit *int `json:"tasks_limit"`
|
||||
ContractorsLimit *int `json:"contractors_limit"`
|
||||
DocumentsLimit *int `json:"documents_limit"`
|
||||
}
|
||||
|
||||
// NewTierLimitsResponse creates a TierLimitsResponse from a model
|
||||
func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse {
|
||||
return &TierLimitsResponse{
|
||||
Tier: string(l.Tier),
|
||||
PropertiesLimit: l.PropertiesLimit,
|
||||
TasksLimit: l.TasksLimit,
|
||||
ContractorsLimit: l.ContractorsLimit,
|
||||
DocumentsLimit: l.DocumentsLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// UsageResponse represents current usage
|
||||
type UsageResponse struct {
|
||||
Properties int64 `json:"properties"`
|
||||
Tasks int64 `json:"tasks"`
|
||||
Contractors int64 `json:"contractors"`
|
||||
Documents int64 `json:"documents"`
|
||||
}
|
||||
|
||||
// SubscriptionStatusResponse represents full subscription status
|
||||
type SubscriptionStatusResponse struct {
|
||||
Subscription *SubscriptionResponse `json:"subscription"`
|
||||
Limits *TierLimitsResponse `json:"limits"`
|
||||
Usage *UsageResponse `json:"usage,omitempty"`
|
||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||
}
|
||||
|
||||
// UpgradeTriggerResponse represents an upgrade trigger
|
||||
type UpgradeTriggerResponse struct {
|
||||
TriggerKey string `json:"trigger_key"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
PromoHTML string `json:"promo_html"`
|
||||
ButtonText string `json:"button_text"`
|
||||
}
|
||||
|
||||
// NewUpgradeTriggerResponse creates an UpgradeTriggerResponse from a model
|
||||
func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse {
|
||||
return &UpgradeTriggerResponse{
|
||||
TriggerKey: t.TriggerKey,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
PromoHTML: t.PromoHTML,
|
||||
ButtonText: t.ButtonText,
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureBenefitResponse represents a feature benefit
|
||||
type FeatureBenefitResponse struct {
|
||||
FeatureName string `json:"feature_name"`
|
||||
FreeTierText string `json:"free_tier_text"`
|
||||
ProTierText string `json:"pro_tier_text"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
}
|
||||
|
||||
// NewFeatureBenefitResponse creates a FeatureBenefitResponse from a model
|
||||
func NewFeatureBenefitResponse(f *models.FeatureBenefit) *FeatureBenefitResponse {
|
||||
return &FeatureBenefitResponse{
|
||||
FeatureName: f.FeatureName,
|
||||
FreeTierText: f.FreeTierText,
|
||||
ProTierText: f.ProTierText,
|
||||
DisplayOrder: f.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
// PromotionResponse represents a promotion
|
||||
type PromotionResponse struct {
|
||||
PromotionID string `json:"promotion_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Link *string `json:"link"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
// NewPromotionResponse creates a PromotionResponse from a model
|
||||
func NewPromotionResponse(p *models.Promotion) *PromotionResponse {
|
||||
return &PromotionResponse{
|
||||
PromotionID: p.PromotionID,
|
||||
Title: p.Title,
|
||||
Message: p.Message,
|
||||
Link: p.Link,
|
||||
StartDate: p.StartDate.Format("2006-01-02"),
|
||||
EndDate: p.EndDate.Format("2006-01-02"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Request Types ===
|
||||
|
||||
// ProcessPurchaseRequest represents an IAP purchase request
|
||||
type ProcessPurchaseRequest struct {
|
||||
ReceiptData string `json:"receipt_data"` // iOS
|
||||
PurchaseToken string `json:"purchase_token"` // Android
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
601
internal/services/task_service.go
Normal file
601
internal/services/task_service.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Task-related errors
|
||||
var (
|
||||
ErrTaskNotFound = errors.New("task not found")
|
||||
ErrTaskAccessDenied = errors.New("you do not have access to this task")
|
||||
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
|
||||
ErrTaskAlreadyArchived = errors.New("task is already archived")
|
||||
ErrCompletionNotFound = errors.New("task completion not found")
|
||||
)
|
||||
|
||||
// TaskService handles task business logic
|
||||
type TaskService struct {
|
||||
taskRepo *repositories.TaskRepository
|
||||
residenceRepo *repositories.ResidenceRepository
|
||||
}
|
||||
|
||||
// NewTaskService creates a new task service
|
||||
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
|
||||
return &TaskService{
|
||||
taskRepo: taskRepo,
|
||||
residenceRepo: residenceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// === Task CRUD ===
|
||||
|
||||
// GetTask gets a task by ID with access check
|
||||
func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access via residence
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListTasks lists all tasks accessible to a user
|
||||
func (s *TaskService) ListTasks(userID uint) (*responses.TaskListResponse, error) {
|
||||
// Get all residence IDs accessible to user
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
if len(residenceIDs) == 0 {
|
||||
return &responses.TaskListResponse{Count: 0, Results: []responses.TaskResponse{}}, nil
|
||||
}
|
||||
|
||||
tasks, err := s.taskRepo.FindByUser(userID, residenceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskListResponse(tasks)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetTasksByResidence gets tasks for a specific residence (kanban board)
|
||||
func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int) (*responses.KanbanBoardResponse, error) {
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
if daysThreshold <= 0 {
|
||||
daysThreshold = 30 // Default
|
||||
}
|
||||
|
||||
board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewKanbanBoardResponse(board, residenceID)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskResponse, error) {
|
||||
// Check residence access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
}
|
||||
|
||||
task := &models.Task{
|
||||
ResidenceID: req.ResidenceID,
|
||||
CreatedByID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
CategoryID: req.CategoryID,
|
||||
PriorityID: req.PriorityID,
|
||||
StatusID: req.StatusID,
|
||||
FrequencyID: req.FrequencyID,
|
||||
AssignedToID: req.AssignedToID,
|
||||
DueDate: req.DueDate,
|
||||
EstimatedCost: req.EstimatedCost,
|
||||
ContractorID: req.ContractorID,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Create(task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
task, err = s.taskRepo.FindByID(task.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates a task
|
||||
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if req.Title != nil {
|
||||
task.Title = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
task.CategoryID = req.CategoryID
|
||||
}
|
||||
if req.PriorityID != nil {
|
||||
task.PriorityID = req.PriorityID
|
||||
}
|
||||
if req.StatusID != nil {
|
||||
task.StatusID = req.StatusID
|
||||
}
|
||||
if req.FrequencyID != nil {
|
||||
task.FrequencyID = req.FrequencyID
|
||||
}
|
||||
if req.AssignedToID != nil {
|
||||
task.AssignedToID = req.AssignedToID
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
task.DueDate = req.DueDate
|
||||
}
|
||||
if req.EstimatedCost != nil {
|
||||
task.EstimatedCost = req.EstimatedCost
|
||||
}
|
||||
if req.ActualCost != nil {
|
||||
task.ActualCost = req.ActualCost
|
||||
}
|
||||
if req.ContractorID != nil {
|
||||
task.ContractorID = req.ContractorID
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Update(task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(task.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteTask deletes a task
|
||||
func (s *TaskService) DeleteTask(taskID, userID uint) error {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
return s.taskRepo.Delete(taskID)
|
||||
}
|
||||
|
||||
// === Task Actions ===
|
||||
|
||||
// MarkInProgress marks a task as in progress
|
||||
func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
// Find "In Progress" status
|
||||
status, err := s.taskRepo.FindStatusByName("In Progress")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CancelTask cancels a task
|
||||
func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
if task.IsCancelled {
|
||||
return nil, ErrTaskAlreadyCancelled
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Cancel(taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UncancelTask uncancels a task
|
||||
func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Uncancel(taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ArchiveTask archives a task
|
||||
func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
if task.IsArchived {
|
||||
return nil, ErrTaskAlreadyArchived
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Archive(taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UnarchiveTask unarchives a task
|
||||
func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
||||
task, err := s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
if err := s.taskRepo.Unarchive(taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
task, err = s.taskRepo.FindByID(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskResponse(task)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// === Task Completions ===
|
||||
|
||||
// CreateCompletion creates a task completion
|
||||
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionResponse, error) {
|
||||
// Get the task
|
||||
task, err := s.taskRepo.FindByID(req.TaskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
completedAt := time.Now().UTC()
|
||||
if req.CompletedAt != nil {
|
||||
completedAt = *req.CompletedAt
|
||||
}
|
||||
|
||||
completion := &models.TaskCompletion{
|
||||
TaskID: req.TaskID,
|
||||
CompletedByID: userID,
|
||||
CompletedAt: completedAt,
|
||||
Notes: req.Notes,
|
||||
ActualCost: req.ActualCost,
|
||||
PhotoURL: req.PhotoURL,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload
|
||||
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskCompletionResponse(completion)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetCompletion gets a task completion by ID
|
||||
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCompletionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access via task's residence
|
||||
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
resp := responses.NewTaskCompletionResponse(completion)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListCompletions lists all task completions for a user
|
||||
func (s *TaskService) ListCompletions(userID uint) (*responses.TaskCompletionListResponse, error) {
|
||||
// Get all residence IDs
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
if len(residenceIDs) == 0 {
|
||||
return &responses.TaskCompletionListResponse{Count: 0, Results: []responses.TaskCompletionResponse{}}, nil
|
||||
}
|
||||
|
||||
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewTaskCompletionListResponse(completions)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteCompletion deletes a task completion
|
||||
func (s *TaskService) DeleteCompletion(completionID, userID uint) error {
|
||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrCompletionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrTaskAccessDenied
|
||||
}
|
||||
|
||||
return s.taskRepo.DeleteCompletion(completionID)
|
||||
}
|
||||
|
||||
// === Lookups ===
|
||||
|
||||
// GetCategories returns all task categories
|
||||
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
|
||||
categories, err := s.taskRepo.GetAllCategories()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.TaskCategoryResponse, len(categories))
|
||||
for i, c := range categories {
|
||||
result[i] = *responses.NewTaskCategoryResponse(&c)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPriorities returns all task priorities
|
||||
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
|
||||
priorities, err := s.taskRepo.GetAllPriorities()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.TaskPriorityResponse, len(priorities))
|
||||
for i, p := range priorities {
|
||||
result[i] = *responses.NewTaskPriorityResponse(&p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatuses returns all task statuses
|
||||
func (s *TaskService) GetStatuses() ([]responses.TaskStatusResponse, error) {
|
||||
statuses, err := s.taskRepo.GetAllStatuses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.TaskStatusResponse, len(statuses))
|
||||
for i, st := range statuses {
|
||||
result[i] = *responses.NewTaskStatusResponse(&st)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFrequencies returns all task frequencies
|
||||
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
||||
frequencies, err := s.taskRepo.GetAllFrequencies()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]responses.TaskFrequencyResponse, len(frequencies))
|
||||
for i, f := range frequencies {
|
||||
result[i] = *responses.NewTaskFrequencyResponse(&f)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user