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.
754 lines
23 KiB
Go
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
|
|
}
|