c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
788 lines
26 KiB
Go
788 lines
26 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. Also bust the subscription
|
|
// status cache so properties_count reflects the new residence.
|
|
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, ownerID)
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(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...)
|
|
// All counts (properties + tasks/contractors/documents that lived in
|
|
// the deleted residence) just dropped for every member, not only the
|
|
// owner.
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(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.
|
|
// Audit C9/H9: the code lookup, membership add, and one-time-code
|
|
// deactivation run as a single locked transaction in the repository, so a
|
|
// code can never be redeemed twice and a deactivation failure aborts the join.
|
|
func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID uint) (*responses.JoinResidenceResponse, error) {
|
|
residenceID, alreadyMember, err := s.residenceRepo.WithContext(ctx).JoinWithShareCode(code, userID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, apperrors.NotFound("error.share_code_invalid")
|
|
}
|
|
return nil, apperrors.Internal(err)
|
|
}
|
|
if alreadyMember {
|
|
return nil, apperrors.Conflict("error.user_already_member")
|
|
}
|
|
|
|
if s.cache != nil {
|
|
// The joining user's residence-IDs cache is now stale, and their
|
|
// subscription status now reflects an extra residence with all of its
|
|
// tasks/contractors/documents.
|
|
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userID)
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
|
}
|
|
|
|
// Get the residence with full details
|
|
residence, err := s.residenceRepo.WithContext(ctx).FindByID(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 lost access to one residence and all of its
|
|
// tasks/contractors/documents — their counts must be recomputed.
|
|
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userIDToRemove)
|
|
_ = s.cache.InvalidateSubscriptionStatusForUsers(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
|
|
}
|