Files
honeyDueAPI/internal/services/residence_service.go
T
Trey t 65a9aae4e5
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
Migrate TaskService + ResidenceService to ctx-aware repos
Every public method on TaskService and ResidenceService now takes
ctx context.Context as the first arg and routes its repo calls through
.WithContext(ctx). With otelgorm registered, this means every API
endpoint backed by these two services produces a flame graph in Jaeger
where the SQL spans nest under the parent HTTP request span — instead
of appearing as orphaned queries.

Endpoints now fully traced (HTTP → service → SQL):
- GET    /api/tasks/                       (already shipped)
- GET    /api/tasks/by-residence/:id/      (already shipped)
- GET    /api/tasks/:id/
- POST   /api/tasks/
- POST   /api/tasks/bulk/
- PUT    /api/tasks/:id/
- DELETE /api/tasks/:id/
- POST   /api/tasks/:id/in-progress/
- POST   /api/tasks/:id/cancel/
- POST   /api/tasks/:id/uncancel/
- POST   /api/tasks/:id/archive/
- POST   /api/tasks/:id/unarchive/
- POST   /api/tasks/:id/complete/
- POST   /api/tasks/:id/quick-complete/
- GET    /api/tasks/completions/* (CRUD)
- GET    /api/static_data/ (categories, priorities, frequencies)
- GET    /api/residences/
- GET    /api/residences/my/
- GET    /api/residences/summary/
- GET    /api/residences/:id/
- POST   /api/residences/
- PUT    /api/residences/:id/
- DELETE /api/residences/:id/
- Share-code + member management endpoints
- GET    /api/residences/:id/report/

Mechanical work: ~50 method signatures, ~80 handler call sites,
~25 test call sites updated. Internal sendTaskCompletedNotification
helper also takes ctx so background notification SQL nests correctly.

The remaining services (ContractorService, DocumentService,
AuthService, NotificationService, SubscriptionService) follow the same
pattern; they continue to emit untraced SQL until migrated.

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

755 lines
24 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
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(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 := s.residenceRepo.WithContext(ctx).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(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(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)
}
// 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")
}
if err := s.residenceRepo.WithContext(ctx).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(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)
}
// 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)
}
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
}