Files
honeyDueAPI/internal/services/residence_service.go
Trey T cb7080c460 Smart onboarding: residence home profile + suggestion engine
14 new optional residence fields (heating, cooling, water heater, roof,
pool, sprinkler, septic, fireplace, garage, basement, attic, exterior,
flooring, landscaping) with JSONB conditions on templates.

Suggestion engine scores templates against home profile: string match
+0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful
degradation from minimal to full profile info.

GET /api/tasks/suggestions/?residence_id=X returns ranked templates.
54 template conditions across 44 templates in seed data.
8 suggestion service tests.
2026-03-30 09:02:03 -05:00

754 lines
23 KiB
Go

package services
import (
"errors"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-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
subscriptionService *SubscriptionService
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
}
// SetSubscriptionService sets the subscription service (used for tier limit enforcement)
func (s *ResidenceService) SetSubscriptionService(subService *SubscriptionService) {
s.subscriptionService = subService
}
// GetResidence gets a residence by ID with access check.
// The `now` parameter is used for timezone-aware completion summary aggregation.
func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time) (*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)
// Attach completion summary (honeycomb grid data)
if s.taskRepo != nil {
summary, err := s.taskRepo.GetCompletionSummary(residenceID, now, 10)
if err != nil {
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("Failed to fetch completion summary")
} else {
resp.CompletionSummary = summary
}
}
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
}
}
}
// P-01: Batch fetch completion summaries in 2 queries total instead of 2*N
summaries, err := s.taskRepo.GetBatchCompletionSummaries(residenceIDs, now, 10)
if err != nil {
log.Warn().Err(err).Msg("Failed to fetch batch completion summaries")
} else {
for i := range residenceResponses {
if summary, ok := summaries[residenceResponses[i].ID]; ok {
residenceResponses[i].CompletionSummary = summary
}
}
}
}
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) {
// Check subscription tier limits (if subscription service is wired up)
if s.subscriptionService != nil {
if err := s.subscriptionService.CheckLimit(ownerID, "properties"); err != nil {
return nil, err
}
}
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,
HeatingType: req.HeatingType,
CoolingType: req.CoolingType,
WaterHeaterType: req.WaterHeaterType,
RoofType: req.RoofType,
ExteriorType: req.ExteriorType,
FlooringPrimary: req.FlooringPrimary,
LandscapingType: req.LandscapingType,
IsPrimary: isPrimary,
IsActive: true,
}
// Apply boolean home profile fields
if req.HasPool != nil {
residence.HasPool = *req.HasPool
}
if req.HasSprinklerSystem != nil {
residence.HasSprinklerSystem = *req.HasSprinklerSystem
}
if req.HasSeptic != nil {
residence.HasSeptic = *req.HasSeptic
}
if req.HasFireplace != nil {
residence.HasFireplace = *req.HasFireplace
}
if req.HasGarage != nil {
residence.HasGarage = *req.HasGarage
}
if req.HasBasement != nil {
residence.HasBasement = *req.HasBasement
}
if req.HasAttic != nil {
residence.HasAttic = *req.HasAttic
}
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
}
// Home Profile fields
if req.HeatingType != nil {
residence.HeatingType = req.HeatingType
}
if req.CoolingType != nil {
residence.CoolingType = req.CoolingType
}
if req.WaterHeaterType != nil {
residence.WaterHeaterType = req.WaterHeaterType
}
if req.RoofType != nil {
residence.RoofType = req.RoofType
}
if req.HasPool != nil {
residence.HasPool = *req.HasPool
}
if req.HasSprinklerSystem != nil {
residence.HasSprinklerSystem = *req.HasSprinklerSystem
}
if req.HasSeptic != nil {
residence.HasSeptic = *req.HasSeptic
}
if req.HasFireplace != nil {
residence.HasFireplace = *req.HasFireplace
}
if req.HasGarage != nil {
residence.HasGarage = *req.HasGarage
}
if req.HasBasement != nil {
residence.HasBasement = *req.HasBasement
}
if req.HasAttic != nil {
residence.HasAttic = *req.HasAttic
}
if req.ExteriorType != nil {
residence.ExteriorType = req.ExteriorType
}
if req.FlooringPrimary != nil {
residence.FlooringPrimary = req.FlooringPrimary
}
if req.LandscapingType != nil {
residence.LandscapingType = req.LandscapingType
}
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 .honeydue 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
log.Error().Err(err).Uint("code_id", shareCode.ID).Msg("Failed to deactivate share code after join")
}
// 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
}