Files
honeyDueAPI/internal/services/residence_service.go
T
Trey t 88fb1751c7
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Cut /api/tasks/ p99 from ~2500ms toward ~150-300ms
Stack of optimizations against the same Hetzner→Neon transatlantic link.
The trace revealed every visible ms was network/proxy overhead — DB
execution itself is sub-millisecond per query (verified via EXPLAIN
ANALYZE: index scans on every hot path).

Connection layer:
- DB_HOST → Neon pooler endpoint (-pooler suffix). PgBouncer
  transaction-mode keeps backend Postgres connections warm so we no
  longer pay the ~110ms Postgres-startup RTT on cold queries.
- GORM pool tuned: MaxIdleConns 10→20, MaxLifetime 600s→1800s,
  MaxIdleTime added (default 0 = never close idle).
- Eager pool warm-up at boot via parallel pings — first user request
  no longer pays the ~440ms TCP+TLS+startup handshake.
- Redis maxmemory-policy noeviction → allkeys-lru. Cache writes will
  evict cold keys instead of erroring at the 256MB limit.

Auth layer:
- TokenCacheTTL 5min → 1 hour (Redis token cache).
- UserCacheTTL 30s → 5min (in-memory User cache, per pod).
- UserCache gains a 5,000-entry LRU cap so a flood of unique users
  can't blow up pod RSS. ~5MB worst-case per pod.
- Token + user lookup collapsed from 2 GORM Preload queries into a
  single INNER JOIN. Saves 1 RTT per cold-cache request.
- Auth middleware's m.db.* now use db.WithContext(ctx) so the SQL
  spans nest under the parent HTTP request in Jaeger.

Service layer:
- TaskService.ListTasks: replaced two-step
  FindResidenceIDsByUser → GetKanbanDataForMultipleResidences
  with a single GetKanbanDataForUser that uses a Postgres subquery
  for residence-access. One round-trip instead of two.
- New CacheService residence-IDs cache: \"residence_ids_user:<id>\"
  with 5-min TTL. Wired into Task/Residence/Contractor/Document
  services for the four hot read paths that need this list.
- Cache invalidation on every relevant mutation: CreateResidence,
  DeleteResidence, JoinWithCode, RemoveUser. DeleteResidence
  invalidates every member of the residence, not just the owner.

What this stacks up to (Hetzner→Neon, before US migration):
  Path                                 Before        After (target)
  Cache-warm authed read               ~800ms        ~100-200ms
  Cache-cold authed read (1st in 1hr)  ~2500ms       ~500-700ms
  First request after deploy           ~2500ms       ~700-900ms

The endgame US-region migration on top of this gets us to ~30-50ms
warm-cache, but we're shippable at ~150ms warm right now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:13:50 -05:00

792 lines
25 KiB
Go

package services
import (
"context"
"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
cache *CacheService
config *config.Config
}
// SetCacheService wires a Redis-backed cache for residence-ID lookups. May
// be nil — service falls through to direct DB queries when unset.
func (s *ResidenceService) SetCacheService(cache *CacheService) {
s.cache = cache
}
// 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(ctx context.Context, residenceID, userID uint, now time.Time) (*responses.ResidenceResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.WithContext(ctx).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(ctx context.Context, userID uint, now time.Time) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, userID uint, now time.Time) (*responses.TotalSummary, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := cachedResidenceIDsForUser(ctx, s.cache, s.residenceRepo, 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(ctx context.Context, 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(ctx, 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.WithContext(ctx).Create(residence); err != nil {
return nil, apperrors.Internal(err)
}
if s.cache != nil {
// Owner now has a new residence — drop cached IDs so the next
// list-residences call doesn't omit it.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, ownerID)
}
// Reload with relations
residence, err := s.residenceRepo.WithContext(ctx).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(ctx context.Context, residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceWithSummaryResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).Update(residence); err != nil {
return nil, apperrors.Internal(err)
}
// Reload with relations
residence, err = s.residenceRepo.WithContext(ctx).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(ctx context.Context, residenceID, userID uint) (*responses.ResidenceDeleteWithSummaryResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.WithContext(ctx).IsOwner(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !isOwner {
return nil, apperrors.Forbidden("error.not_residence_owner")
}
// Capture all member IDs before delete so we can invalidate their caches.
var affectedUserIDs []uint
if s.cache != nil {
if members, _ := s.residenceRepo.WithContext(ctx).GetResidenceUsers(residenceID); members != nil {
affectedUserIDs = make([]uint, 0, len(members)+1)
affectedUserIDs = append(affectedUserIDs, userID) // owner
for _, m := range members {
if m.ID != userID {
affectedUserIDs = append(affectedUserIDs, m.ID)
}
}
}
}
if err := s.residenceRepo.WithContext(ctx).Delete(residenceID); err != nil {
return nil, apperrors.Internal(err)
}
if s.cache != nil && len(affectedUserIDs) > 0 {
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, affectedUserIDs...)
}
// 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(ctx context.Context, residenceID, userID uint, expiresInHours int) (*responses.GenerateShareCodeResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, residenceID, userID uint) (*responses.ShareCodeResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) {
// Check ownership (only owners can share residences)
isOwner, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).FindByID(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Get the user who's sharing
user, err := s.userRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, code string, userID uint) (*responses.JoinResidenceResponse, error) {
// Find the share code
shareCode, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).AddUser(shareCode.ResidenceID, userID); err != nil {
return nil, apperrors.Internal(err)
}
if s.cache != nil {
// The joining user's residence-IDs cache is now stale.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userID)
}
// Mark share code as used (one-time use)
if err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, residenceID, userID uint) ([]responses.ResidenceUserResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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(ctx context.Context, residenceID, userIDToRemove, requestingUserID uint) error {
// Check ownership
isOwner, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).RemoveUser(residenceID, userIDToRemove); err != nil {
return apperrors.Internal(err)
}
if s.cache != nil {
// The removed user's residence-IDs cache is now stale.
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userIDToRemove)
}
return nil
}
// GetResidenceTypes returns all residence types
func (s *ResidenceService) GetResidenceTypes(ctx context.Context) ([]responses.ResidenceTypeResponse, error) {
types, err := s.residenceRepo.WithContext(ctx).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(ctx context.Context, residenceID, userID uint) (*TasksReportResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.WithContext(ctx).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.WithContext(ctx).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.WithContext(ctx).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
}