package services import ( "errors" "time" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/apperrors" "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" "github.com/treytartt/casera-api/internal/task/predicates" ) // Common errors (deprecated - kept for reference, now using apperrors package) // Most errors have been migrated to apperrors, but some are still used by other handlers // TODO: Migrate handlers to use apperrors instead of these constants 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, apperrors.Internal(err) } if !hasAccess { return nil, apperrors.Forbidden("error.residence_access_denied") } residence, err := s.residenceRepo.FindByID(residenceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.NotFound("error.residence_not_found") } return nil, apperrors.Internal(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, apperrors.Internal(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. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. // // NOTE: Summary statistics (TotalTasks, TotalOverdue, etc.) are calculated client-side // from kanban data for performance. Only per-residence OverdueCount is returned from the server. func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*responses.MyResidencesResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, apperrors.Internal(err) } residenceResponses := responses.NewResidenceListResponse(residences) // Get per-residence overdue counts for residence card badges if s.taskRepo != nil && len(residences) > 0 { residenceIDs := make([]uint, len(residences)) for i, r := range residences { residenceIDs[i] = r.ID } overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now) if err == nil && overdueCounts != nil { for i := range residenceResponses { if count, ok := overdueCounts[residenceResponses[i].ID]; ok { residenceResponses[i].OverdueCount = count } } } } return &responses.MyResidencesResponse{ Residences: residenceResponses, }, 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. // // DEPRECATED: Summary statistics are now calculated client-side from kanban data. // This endpoint only returns TotalResidences; other fields will be zero. // Clients should use calculateSummaryFromKanban() instead. func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) { // Get residence IDs (lightweight - no preloads) residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID) if err != nil { return nil, apperrors.Internal(err) } // Summary statistics are calculated client-side from kanban data. // We only return TotalResidences here. return &responses.TotalSummary{ TotalResidences: len(residenceIDs), }, nil } // getSummaryForUser returns an empty summary placeholder. // DEPRECATED: Summary calculation has been removed from CRUD responses for performance. // Clients should calculate summary from kanban data instead (which already includes all tasks). // The summary field is kept in responses for backward compatibility but will always be empty. // For actual summary data, use GetSummary() directly or rely on my-residences/kanban endpoints. func (s *ResidenceService) getSummaryForUser(_ uint) responses.TotalSummary { // Return empty summary - clients should calculate from kanban data return responses.TotalSummary{} } // CreateResidence creates a new residence and returns it with updated summary func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceWithSummaryResponse, 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, apperrors.Internal(err) } // Reload with relations residence, err := s.residenceRepo.FindByID(residence.ID) if err != nil { return nil, apperrors.Internal(err) } // Get updated summary summary := s.getSummaryForUser(ownerID) return &responses.ResidenceWithSummaryResponse{ Data: responses.NewResidenceResponse(residence), Summary: summary, }, nil } // UpdateResidence updates a residence and returns it with updated summary func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceWithSummaryResponse, error) { // Check ownership isOwner, err := s.residenceRepo.IsOwner(residenceID, userID) if err != nil { return nil, apperrors.Internal(err) } if !isOwner { return nil, apperrors.Forbidden("error.not_residence_owner") } residence, err := s.residenceRepo.FindByID(residenceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.NotFound("error.residence_not_found") } return nil, apperrors.Internal(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, apperrors.Internal(err) } // Reload with relations residence, err = s.residenceRepo.FindByID(residence.ID) if err != nil { return nil, apperrors.Internal(err) } // Get updated summary summary := s.getSummaryForUser(userID) return &responses.ResidenceWithSummaryResponse{ Data: responses.NewResidenceResponse(residence), Summary: summary, }, nil } // DeleteResidence soft-deletes a residence and returns updated summary func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses.ResidenceDeleteWithSummaryResponse, error) { // Check ownership isOwner, err := s.residenceRepo.IsOwner(residenceID, userID) if err != nil { return nil, apperrors.Internal(err) } if !isOwner { return nil, apperrors.Forbidden("error.not_residence_owner") } if err := s.residenceRepo.Delete(residenceID); err != nil { return nil, apperrors.Internal(err) } // Get updated summary summary := s.getSummaryForUser(userID) return &responses.ResidenceDeleteWithSummaryResponse{ Data: "residence deleted", Summary: summary, }, nil } // 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, apperrors.Internal(err) } if !isOwner { return nil, apperrors.Forbidden("error.not_residence_owner") } // 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, apperrors.Internal(err) } return &responses.GenerateShareCodeResponse{ Message: "Share code generated successfully", ShareCode: responses.NewShareCodeResponse(shareCode), }, nil } // GetShareCode retrieves the active share code for a residence (if any) func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.ShareCodeResponse, error) { // Check access hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) if err != nil { return nil, apperrors.Internal(err) } if !hasAccess { return nil, apperrors.Forbidden("error.residence_access_denied") } shareCode, err := s.residenceRepo.GetActiveShareCode(residenceID) if err != nil { return nil, apperrors.Internal(err) } if shareCode == nil { return nil, nil } resp := responses.NewShareCodeResponse(shareCode) return &resp, 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, apperrors.Internal(err) } if !isOwner { return nil, apperrors.Forbidden("error.not_residence_owner") } // Get residence details for the package residence, err := s.residenceRepo.FindByID(residenceID) if err != nil { return nil, apperrors.Internal(err) } // Get the user who's sharing user, err := s.userRepo.FindByID(userID) if err != nil { return nil, apperrors.Internal(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, apperrors.Internal(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, apperrors.NotFound("error.share_code_invalid") } return nil, apperrors.Internal(err) } // Check if already a member hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID) if err != nil { return nil, apperrors.Internal(err) } if hasAccess { return nil, apperrors.Conflict("error.user_already_member") } // Add user to residence if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil { return nil, apperrors.Internal(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, apperrors.Internal(err) } // Get updated summary for the user summary := s.getSummaryForUser(userID) return &responses.JoinResidenceResponse{ Message: "Successfully joined residence", Residence: responses.NewResidenceResponse(residence), Summary: summary, }, 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, apperrors.Internal(err) } if !hasAccess { return nil, apperrors.Forbidden("error.residence_access_denied") } users, err := s.residenceRepo.GetResidenceUsers(residenceID) if err != nil { return nil, apperrors.Internal(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 apperrors.Internal(err) } if !isOwner { return apperrors.Forbidden("error.not_residence_owner") } // Cannot remove the owner if userIDToRemove == requestingUserID { return apperrors.BadRequest("error.cannot_remove_owner") } // Check if the residence exists residence, err := s.residenceRepo.FindByIDSimple(residenceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperrors.NotFound("error.residence_not_found") } return apperrors.Internal(err) } // Cannot remove the owner if userIDToRemove == residence.OwnerID { return apperrors.BadRequest("error.cannot_remove_owner") } if err := s.residenceRepo.RemoveUser(residenceID, userIDToRemove); err != nil { return apperrors.Internal(err) } return nil } // GetResidenceTypes returns all residence types func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) { types, err := s.residenceRepo.GetAllResidenceTypes() if err != nil { return nil, apperrors.Internal(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, apperrors.Internal(err) } if !hasAccess { return nil, apperrors.Forbidden("error.residence_access_denied") } // Get residence details residence, err := s.residenceRepo.FindByIDSimple(residenceID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.NotFound("error.residence_not_found") } return nil, apperrors.Internal(err) } // Get all tasks for the residence tasks, err := s.residenceRepo.GetTasksForReport(residenceID) if err != nil { return nil, apperrors.Internal(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 { // Use predicates from internal/task/predicates as single source of truth isCompleted := predicates.IsCompleted(&task) isOverdue := predicates.IsOverdue(&task, now) 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.InProgress { taskData.Status = "In Progress" } // Use effective date for report (NextDueDate ?? DueDate) effectiveDate := predicates.EffectiveDate(&task) if effectiveDate != nil { taskData.DueDate = effectiveDate } report.Tasks[i] = taskData if isCompleted { report.Completed++ } else if predicates.IsActive(&task) { report.Pending++ if isOverdue { report.Overdue++ } } } return report, nil }