Initial commit: MyCrib API in Go
Complete rewrite of Django REST API to Go with: - Gin web framework for HTTP routing - GORM for database operations - GoAdmin for admin panel - Gorush integration for push notifications - Redis for caching and job queues Features implemented: - User authentication (login, register, logout, password reset) - Residence management (CRUD, sharing, share codes) - Task management (CRUD, kanban board, completions) - Contractor management (CRUD, specialties) - Document management (CRUD, warranties) - Notifications (preferences, push notifications) - Subscription management (tiers, limits) Infrastructure: - Docker Compose for local development - Database migrations and seed data - Admin panel for data management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
381
internal/services/residence_service.go
Normal file
381
internal/services/residence_service.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
||||
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ResidenceListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceListResponse(residences)
|
||||
return &resp, 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.ResidenceListResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: In Phase 4, this will include tasks and completions
|
||||
resp := responses.NewResidenceListResponse(residences)
|
||||
return &resp, 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
|
||||
}
|
||||
Reference in New Issue
Block a user