Files
honeyDueAPI/internal/services/residence_service.go
Trey t c7dc56e2d2 Rebrand from MyCrib to Casera
- Update Go module from mycrib-api to casera-api
- Update all import statements across 69 Go files
- Update admin panel branding (title, sidebar, login form)
- Update email templates (subjects, bodies, signatures)
- Update PDF report generation branding
- Update Docker container names and network
- Update config defaults (database name, email sender, APNS topic)
- Update README and documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 21:10:48 -06:00

513 lines
14 KiB
Go

package services
import (
"errors"
"time"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
)
// Common errors
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
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
}
// GetResidence gets a residence by ID with access check
func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.ResidenceResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
}
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrResidenceNotFound
}
return nil, err
}
resp := responses.NewResidenceResponse(residence)
return &resp, nil
}
// ListResidences lists all residences accessible to a user
func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, 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
func (s *ResidenceService) GetMyResidences(userID uint) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
}
residenceResponses := responses.NewResidenceListResponse(residences)
// Build summary with real task statistics
summary := responses.TotalSummary{
TotalResidences: len(residences),
}
// Get task statistics if task repository is available
if s.taskRepo != nil && len(residences) > 0 {
// Collect residence IDs
residenceIDs := make([]uint, len(residences))
for i, r := range residences {
residenceIDs[i] = r.ID
}
// Get aggregated statistics
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs)
if err == nil && stats != nil {
summary.TotalTasks = stats.TotalTasks
summary.TotalPending = stats.TotalPending
summary.TotalOverdue = stats.TotalOverdue
summary.TasksDueNextWeek = stats.TasksDueNextWeek
summary.TasksDueNextMonth = stats.TasksDueNextMonth
}
}
return &responses.MyResidencesResponse{
Residences: residenceResponses,
Summary: summary,
}, nil
}
// CreateResidence creates a new residence
func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest, ownerID uint) (*responses.ResidenceResponse, error) {
// TODO: Check subscription tier limits
// count, err := s.residenceRepo.CountByOwner(ownerID)
// if err != nil {
// return nil, err
// }
// Check against tier limits...
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,
IsPrimary: isPrimary,
IsActive: true,
}
if err := s.residenceRepo.Create(residence); err != nil {
return nil, err
}
// Reload with relations
residence, err := s.residenceRepo.FindByID(residence.ID)
if err != nil {
return nil, err
}
resp := responses.NewResidenceResponse(residence)
return &resp, nil
}
// UpdateResidence updates a residence
func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *requests.UpdateResidenceRequest) (*responses.ResidenceResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
}
if !isOwner {
return nil, ErrNotResidenceOwner
}
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrResidenceNotFound
}
return nil, 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
}
if err := s.residenceRepo.Update(residence); err != nil {
return nil, err
}
// Reload with relations
residence, err = s.residenceRepo.FindByID(residence.ID)
if err != nil {
return nil, err
}
resp := responses.NewResidenceResponse(residence)
return &resp, nil
}
// DeleteResidence soft-deletes a residence
func (s *ResidenceService) DeleteResidence(residenceID, userID uint) error {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return err
}
if !isOwner {
return ErrNotResidenceOwner
}
return s.residenceRepo.Delete(residenceID)
}
// GenerateShareCode generates a new share code for a residence
func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresInHours int) (*responses.GenerateShareCodeResponse, error) {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
}
if !isOwner {
return nil, ErrNotResidenceOwner
}
// Default to 24 hours if not specified
if expiresInHours <= 0 {
expiresInHours = 24
}
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
if err != nil {
return nil, err
}
return &responses.GenerateShareCodeResponse{
Message: "Share code generated successfully",
ShareCode: responses.NewShareCodeResponse(shareCode),
}, nil
}
// JoinWithCode allows a user to join a residence using a share code
func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.JoinResidenceResponse, error) {
// Find the share code
shareCode, err := s.residenceRepo.FindShareCodeByCode(code)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrShareCodeInvalid
}
return nil, err
}
// Check if already a member
hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID)
if err != nil {
return nil, err
}
if hasAccess {
return nil, ErrUserAlreadyMember
}
// Add user to residence
if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil {
return nil, err
}
// Get the residence with full details
residence, err := s.residenceRepo.FindByID(shareCode.ResidenceID)
if err != nil {
return nil, err
}
return &responses.JoinResidenceResponse{
Message: "Successfully joined residence",
Residence: responses.NewResidenceResponse(residence),
}, nil
}
// GetResidenceUsers returns all users with access to a residence
func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]responses.ResidenceUserResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
}
users, err := s.residenceRepo.GetResidenceUsers(residenceID)
if err != nil {
return nil, err
}
result := make([]responses.ResidenceUserResponse, len(users))
for i, user := range users {
result[i] = *responses.NewResidenceUserResponse(&user)
}
return result, nil
}
// RemoveUser removes a user from a residence (owner only)
func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUserID uint) error {
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, requestingUserID)
if err != nil {
return err
}
if !isOwner {
return ErrNotResidenceOwner
}
// Cannot remove the owner
if userIDToRemove == requestingUserID {
return ErrCannotRemoveOwner
}
// Check if the residence exists
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrResidenceNotFound
}
return err
}
// Cannot remove the owner
if userIDToRemove == residence.OwnerID {
return ErrCannotRemoveOwner
}
return s.residenceRepo.RemoveUser(residenceID, userIDToRemove)
}
// GetResidenceTypes returns all residence types
func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) {
types, err := s.residenceRepo.GetAllResidenceTypes()
if err != nil {
return nil, err
}
result := make([]responses.ResidenceTypeResponse, len(types))
for i, t := range types {
result[i] = *responses.NewResidenceTypeResponse(&t)
}
return result, nil
}
// TaskReportData represents task data for a report
type TaskReportData struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category string `json:"category"`
Priority string `json:"priority"`
Status string `json:"status"`
DueDate *time.Time `json:"due_date,omitempty"`
IsCompleted bool `json:"is_completed"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
}
// TasksReportResponse represents the generated tasks report
type TasksReportResponse struct {
ResidenceID uint `json:"residence_id"`
ResidenceName string `json:"residence_name"`
GeneratedAt time.Time `json:"generated_at"`
TotalTasks int `json:"total_tasks"`
Completed int `json:"completed"`
Pending int `json:"pending"`
Overdue int `json:"overdue"`
Tasks []TaskReportData `json:"tasks"`
}
// GenerateTasksReport generates a report of all tasks for a residence
func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*TasksReportResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
}
// Get residence details
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
if err != nil {
return nil, ErrResidenceNotFound
}
// Get all tasks for the residence
tasks, err := s.residenceRepo.GetTasksForReport(residenceID)
if err != nil {
return nil, 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 {
// Determine if task is completed (has completions)
isCompleted := len(task.Completions) > 0
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.Status != nil {
taskData.Status = task.Status.Name
}
if task.DueDate != nil {
taskData.DueDate = task.DueDate
}
report.Tasks[i] = taskData
if isCompleted {
report.Completed++
} else if !task.IsCancelled && !task.IsArchived {
report.Pending++
if task.DueDate != nil && task.DueDate.Before(now) {
report.Overdue++
}
}
}
return report, nil
}