e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
755 lines
24 KiB
Go
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(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)
|
|
}
|
|
|
|
// 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
|
|
}
|