package services import ( "errors" "time" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/dto/responses" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-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 taskRepo *repositories.TaskRepository 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, } } // SetTaskRepository sets the task repository (used for task statistics) func (s *ResidenceService) SetTaskRepository(taskRepo *repositories.TaskRepository) { s.taskRepo = taskRepo } // 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.ResidenceResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err } return responses.NewResidenceListResponse(residences), 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.MyResidencesResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err } residenceResponses := responses.NewResidenceListResponse(residences) // Build summary with real task statistics summary := responses.TotalSummary{ TotalResidences: len(residences), } // Get task statistics if task repository is available if s.taskRepo != nil && len(residences) > 0 { // Collect residence IDs residenceIDs := make([]uint, len(residences)) for i, r := range residences { residenceIDs[i] = r.ID } // Get aggregated statistics stats, err := s.taskRepo.GetTaskStatistics(residenceIDs) if err == nil && stats != nil { summary.TotalTasks = stats.TotalTasks summary.TotalPending = stats.TotalPending summary.TotalOverdue = stats.TotalOverdue summary.TasksDueNextWeek = stats.TasksDueNextWeek summary.TasksDueNextMonth = stats.TasksDueNextMonth } } return &responses.MyResidencesResponse{ Residences: residenceResponses, Summary: summary, }, nil } // GetSummary returns just the task summary statistics for a user's residences // This is a lightweight endpoint for refreshing summary counts without full residence data func (s *ResidenceService) GetSummary(userID uint) (*responses.TotalSummary, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err } summary := &responses.TotalSummary{ TotalResidences: len(residences), } // Get task statistics if task repository is available if s.taskRepo != nil && len(residences) > 0 { // Collect residence IDs residenceIDs := make([]uint, len(residences)) for i, r := range residences { residenceIDs[i] = r.ID } // Get aggregated statistics stats, err := s.taskRepo.GetTaskStatistics(residenceIDs) if err == nil && stats != nil { summary.TotalTasks = stats.TotalTasks summary.TotalPending = stats.TotalPending summary.TotalOverdue = stats.TotalOverdue summary.TasksDueNextWeek = stats.TasksDueNextWeek summary.TasksDueNextMonth = stats.TasksDueNextMonth } } return summary, 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 } // GenerateSharePackage generates a share code and returns package metadata for .casera file func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) { // Check ownership (only owners can share residences) isOwner, err := s.residenceRepo.IsOwner(residenceID, userID) if err != nil { return nil, err } if !isOwner { return nil, ErrNotResidenceOwner } // Get residence details for the package residence, err := s.residenceRepo.FindByID(residenceID) if err != nil { return nil, err } // Get the user who's sharing user, err := s.userRepo.FindByID(userID) if err != nil { return nil, err } // Default to 24 hours if not specified if expiresInHours <= 0 { expiresInHours = 24 } // Generate the share code shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour) if err != nil { return nil, err } return &responses.SharePackageResponse{ ShareCode: shareCode.Code, ResidenceName: residence.Name, SharedBy: user.Email, ExpiresAt: shareCode.ExpiresAt, }, 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 } // Mark share code as used (one-time use) if err := s.residenceRepo.DeactivateShareCode(shareCode.ID); err != nil { // Log the error but don't fail the join - the user has already been added // The code will just be usable by others until it expires } // 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 } // TaskReportData represents task data for a report type TaskReportData struct { ID uint `json:"id"` Title string `json:"title"` Description string `json:"description,omitempty"` Category string `json:"category"` Priority string `json:"priority"` Status string `json:"status"` DueDate *time.Time `json:"due_date,omitempty"` IsCompleted bool `json:"is_completed"` IsCancelled bool `json:"is_cancelled"` IsArchived bool `json:"is_archived"` } // TasksReportResponse represents the generated tasks report type TasksReportResponse struct { ResidenceID uint `json:"residence_id"` ResidenceName string `json:"residence_name"` GeneratedAt time.Time `json:"generated_at"` TotalTasks int `json:"total_tasks"` Completed int `json:"completed"` Pending int `json:"pending"` Overdue int `json:"overdue"` Tasks []TaskReportData `json:"tasks"` } // GenerateTasksReport generates a report of all tasks for a residence func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*TasksReportResponse, error) { // Check access hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) if err != nil { return nil, err } if !hasAccess { return nil, ErrResidenceAccessDenied } // Get residence details residence, err := s.residenceRepo.FindByIDSimple(residenceID) if err != nil { return nil, ErrResidenceNotFound } // Get all tasks for the residence tasks, err := s.residenceRepo.GetTasksForReport(residenceID) if err != nil { return nil, err } now := time.Now().UTC() report := &TasksReportResponse{ ResidenceID: residence.ID, ResidenceName: residence.Name, GeneratedAt: now, TotalTasks: len(tasks), Tasks: make([]TaskReportData, len(tasks)), } for i, task := range tasks { // Determine if task is completed (has completions) isCompleted := len(task.Completions) > 0 taskData := TaskReportData{ ID: task.ID, Title: task.Title, Description: task.Description, IsCompleted: isCompleted, IsCancelled: task.IsCancelled, IsArchived: task.IsArchived, } if task.Category != nil { taskData.Category = task.Category.Name } if task.Priority != nil { taskData.Priority = task.Priority.Name } if task.Status != nil { taskData.Status = task.Status.Name } if task.DueDate != nil { taskData.DueDate = task.DueDate } report.Tasks[i] = taskData if isCompleted { report.Completed++ } else if !task.IsCancelled && !task.IsArchived { report.Pending++ if task.DueDate != nil && task.DueDate.Before(now) { report.Overdue++ } } } return report, nil }