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:
Trey t
2025-11-26 20:07:16 -06:00
commit 1f12f3f62a
78 changed files with 13821 additions and 0 deletions

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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>&copy; %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>&copy; %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>&copy; %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>&copy; %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
}

View 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"`
}

View 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
}

View 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"`
}

View 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
}