Migrate from Gin to Echo framework and add comprehensive integration tests

Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -2,11 +2,11 @@ package dto
// PaginationParams holds pagination query parameters
type PaginationParams struct {
Page int `form:"page" binding:"omitempty,min=1"`
PerPage int `form:"per_page" binding:"omitempty,min=1,max=10000"`
Page int `form:"page" validate:"omitempty,min=1"`
PerPage int `form:"per_page" validate:"omitempty,min=1,max=10000"`
Search string `form:"search"`
SortBy string `form:"sort_by"`
SortDir string `form:"sort_dir" binding:"omitempty,oneof=asc desc"`
SortDir string `form:"sort_dir" validate:"omitempty,oneof=asc desc"`
}
// GetPage returns the page number with default
@@ -52,12 +52,12 @@ type UserFilters struct {
// CreateUserRequest for creating a new user
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=150"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name" binding:"max=150"`
LastName string `json:"last_name" binding:"max=150"`
PhoneNumber string `json:"phone_number" binding:"max=20"`
Username string `json:"username" validate:"required,min=3,max=150"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"max=150"`
LastName string `json:"last_name" validate:"max=150"`
PhoneNumber string `json:"phone_number" validate:"max=20"`
IsActive *bool `json:"is_active"`
IsStaff *bool `json:"is_staff"`
IsSuperuser *bool `json:"is_superuser"`
@@ -65,12 +65,12 @@ type CreateUserRequest struct {
// UpdateUserRequest for updating a user
type UpdateUserRequest struct {
Username *string `json:"username" binding:"omitempty,min=3,max=150"`
Email *string `json:"email" binding:"omitempty,email"`
Password *string `json:"password" binding:"omitempty,min=8"`
FirstName *string `json:"first_name" binding:"omitempty,max=150"`
LastName *string `json:"last_name" binding:"omitempty,max=150"`
PhoneNumber *string `json:"phone_number" binding:"omitempty,max=20"`
Username *string `json:"username" validate:"omitempty,min=3,max=150"`
Email *string `json:"email" validate:"omitempty,email"`
Password *string `json:"password" validate:"omitempty,min=8"`
FirstName *string `json:"first_name" validate:"omitempty,max=150"`
LastName *string `json:"last_name" validate:"omitempty,max=150"`
PhoneNumber *string `json:"phone_number" validate:"omitempty,max=20"`
IsActive *bool `json:"is_active"`
IsStaff *bool `json:"is_staff"`
IsSuperuser *bool `json:"is_superuser"`
@@ -79,7 +79,7 @@ type UpdateUserRequest struct {
// BulkDeleteRequest for bulk delete operations
type BulkDeleteRequest struct {
IDs []uint `json:"ids" binding:"required,min=1"`
IDs []uint `json:"ids" validate:"required,min=1"`
}
// ResidenceFilters holds residence-specific filter parameters
@@ -92,14 +92,14 @@ type ResidenceFilters struct {
// UpdateResidenceRequest for updating a residence
type UpdateResidenceRequest struct {
OwnerID *uint `json:"owner_id"`
Name *string `json:"name" binding:"omitempty,max=200"`
Name *string `json:"name" validate:"omitempty,max=200"`
PropertyTypeID *uint `json:"property_type_id"`
StreetAddress *string `json:"street_address" binding:"omitempty,max=255"`
ApartmentUnit *string `json:"apartment_unit" binding:"omitempty,max=50"`
City *string `json:"city" binding:"omitempty,max=100"`
StateProvince *string `json:"state_province" binding:"omitempty,max=100"`
PostalCode *string `json:"postal_code" binding:"omitempty,max=20"`
Country *string `json:"country" binding:"omitempty,max=100"`
StreetAddress *string `json:"street_address" validate:"omitempty,max=255"`
ApartmentUnit *string `json:"apartment_unit" validate:"omitempty,max=50"`
City *string `json:"city" validate:"omitempty,max=100"`
StateProvince *string `json:"state_province" validate:"omitempty,max=100"`
PostalCode *string `json:"postal_code" validate:"omitempty,max=20"`
Country *string `json:"country" validate:"omitempty,max=100"`
Bedrooms *int `json:"bedrooms"`
Bathrooms *float64 `json:"bathrooms"`
SquareFootage *int `json:"square_footage"`
@@ -128,7 +128,7 @@ type UpdateTaskRequest struct {
ResidenceID *uint `json:"residence_id"`
CreatedByID *uint `json:"created_by_id"`
AssignedToID *uint `json:"assigned_to_id"`
Title *string `json:"title" binding:"omitempty,max=200"`
Title *string `json:"title" validate:"omitempty,max=200"`
Description *string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
@@ -156,16 +156,16 @@ type ContractorFilters struct {
type UpdateContractorRequest struct {
ResidenceID *uint `json:"residence_id"`
CreatedByID *uint `json:"created_by_id"`
Name *string `json:"name" binding:"omitempty,max=200"`
Company *string `json:"company" binding:"omitempty,max=200"`
Phone *string `json:"phone" binding:"omitempty,max=20"`
Email *string `json:"email" binding:"omitempty,email"`
Website *string `json:"website" binding:"omitempty,max=200"`
Name *string `json:"name" validate:"omitempty,max=200"`
Company *string `json:"company" validate:"omitempty,max=200"`
Phone *string `json:"phone" validate:"omitempty,max=20"`
Email *string `json:"email" validate:"omitempty,email"`
Website *string `json:"website" validate:"omitempty,max=200"`
Notes *string `json:"notes"`
StreetAddress *string `json:"street_address" binding:"omitempty,max=255"`
City *string `json:"city" binding:"omitempty,max=100"`
StateProvince *string `json:"state_province" binding:"omitempty,max=100"`
PostalCode *string `json:"postal_code" binding:"omitempty,max=20"`
StreetAddress *string `json:"street_address" validate:"omitempty,max=255"`
City *string `json:"city" validate:"omitempty,max=100"`
StateProvince *string `json:"state_province" validate:"omitempty,max=100"`
PostalCode *string `json:"postal_code" validate:"omitempty,max=20"`
Rating *float64 `json:"rating"`
IsFavorite *bool `json:"is_favorite"`
IsActive *bool `json:"is_active"`
@@ -184,24 +184,24 @@ type DocumentFilters struct {
type UpdateDocumentRequest struct {
ResidenceID *uint `json:"residence_id"`
CreatedByID *uint `json:"created_by_id"`
Title *string `json:"title" binding:"omitempty,max=200"`
Title *string `json:"title" validate:"omitempty,max=200"`
Description *string `json:"description"`
DocumentType *string `json:"document_type"`
FileURL *string `json:"file_url" binding:"omitempty,max=500"`
FileName *string `json:"file_name" binding:"omitempty,max=255"`
FileURL *string `json:"file_url" validate:"omitempty,max=500"`
FileName *string `json:"file_name" validate:"omitempty,max=255"`
FileSize *int64 `json:"file_size"`
MimeType *string `json:"mime_type" binding:"omitempty,max=100"`
MimeType *string `json:"mime_type" validate:"omitempty,max=100"`
PurchaseDate *string `json:"purchase_date"`
ExpiryDate *string `json:"expiry_date"`
PurchasePrice *float64 `json:"purchase_price"`
Vendor *string `json:"vendor" binding:"omitempty,max=200"`
SerialNumber *string `json:"serial_number" binding:"omitempty,max=100"`
ModelNumber *string `json:"model_number" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,max=200"`
ProviderContact *string `json:"provider_contact" binding:"omitempty,max=200"`
ClaimPhone *string `json:"claim_phone" binding:"omitempty,max=50"`
ClaimEmail *string `json:"claim_email" binding:"omitempty,email"`
ClaimWebsite *string `json:"claim_website" binding:"omitempty,max=500"`
Vendor *string `json:"vendor" validate:"omitempty,max=200"`
SerialNumber *string `json:"serial_number" validate:"omitempty,max=100"`
ModelNumber *string `json:"model_number" validate:"omitempty,max=100"`
Provider *string `json:"provider" validate:"omitempty,max=200"`
ProviderContact *string `json:"provider_contact" validate:"omitempty,max=200"`
ClaimPhone *string `json:"claim_phone" validate:"omitempty,max=50"`
ClaimEmail *string `json:"claim_email" validate:"omitempty,email"`
ClaimWebsite *string `json:"claim_website" validate:"omitempty,max=500"`
Notes *string `json:"notes"`
TaskID *uint `json:"task_id"`
IsActive *bool `json:"is_active"`
@@ -218,8 +218,8 @@ type NotificationFilters struct {
// UpdateNotificationRequest for updating a notification
type UpdateNotificationRequest struct {
Title *string `json:"title" binding:"omitempty,max=200"`
Body *string `json:"body" binding:"omitempty,max=1000"`
Title *string `json:"title" validate:"omitempty,max=200"`
Body *string `json:"body" validate:"omitempty,max=1000"`
Read *bool `json:"read"`
}
@@ -235,10 +235,10 @@ type SubscriptionFilters struct {
// UpdateSubscriptionRequest for updating a subscription
type UpdateSubscriptionRequest struct {
Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"`
Tier *string `json:"tier" validate:"omitempty,oneof=free premium pro"`
AutoRenew *bool `json:"auto_renew"`
IsFree *bool `json:"is_free"`
Platform *string `json:"platform" binding:"omitempty,max=20"`
Platform *string `json:"platform" validate:"omitempty,max=20"`
SubscribedAt *string `json:"subscribed_at"`
ExpiresAt *string `json:"expires_at"`
CancelledAt *string `json:"cancelled_at"`
@@ -246,15 +246,15 @@ type UpdateSubscriptionRequest struct {
// CreateResidenceRequest for creating a new residence
type CreateResidenceRequest struct {
OwnerID uint `json:"owner_id" binding:"required"`
Name string `json:"name" binding:"required,max=200"`
OwnerID uint `json:"owner_id" validate:"required"`
Name string `json:"name" validate:"required,max=200"`
PropertyTypeID *uint `json:"property_type_id"`
StreetAddress string `json:"street_address" binding:"max=255"`
ApartmentUnit string `json:"apartment_unit" binding:"max=50"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
Country string `json:"country" binding:"max=100"`
StreetAddress string `json:"street_address" validate:"max=255"`
ApartmentUnit string `json:"apartment_unit" validate:"max=50"`
City string `json:"city" validate:"max=100"`
StateProvince string `json:"state_province" validate:"max=100"`
PostalCode string `json:"postal_code" validate:"max=20"`
Country string `json:"country" validate:"max=100"`
Bedrooms *int `json:"bedrooms"`
Bathrooms *float64 `json:"bathrooms"`
SquareFootage *int `json:"square_footage"`
@@ -265,9 +265,9 @@ type CreateResidenceRequest struct {
// CreateTaskRequest for creating a new task
type CreateTaskRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
ResidenceID uint `json:"residence_id" validate:"required"`
CreatedByID uint `json:"created_by_id" validate:"required"`
Title string `json:"title" validate:"required,max=200"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
@@ -282,51 +282,51 @@ type CreateTaskRequest struct {
// CreateContractorRequest for creating a new contractor
type CreateContractorRequest struct {
ResidenceID *uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Name string `json:"name" binding:"required,max=200"`
Company string `json:"company" binding:"max=200"`
Phone string `json:"phone" binding:"max=20"`
Email string `json:"email" binding:"omitempty,email"`
Website string `json:"website" binding:"max=200"`
CreatedByID uint `json:"created_by_id" validate:"required"`
Name string `json:"name" validate:"required,max=200"`
Company string `json:"company" validate:"max=200"`
Phone string `json:"phone" validate:"max=20"`
Email string `json:"email" validate:"omitempty,email"`
Website string `json:"website" validate:"max=200"`
Notes string `json:"notes"`
StreetAddress string `json:"street_address" binding:"max=255"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
StreetAddress string `json:"street_address" validate:"max=255"`
City string `json:"city" validate:"max=100"`
StateProvince string `json:"state_province" validate:"max=100"`
PostalCode string `json:"postal_code" validate:"max=20"`
IsFavorite bool `json:"is_favorite"`
SpecialtyIDs []uint `json:"specialty_ids"`
}
// CreateDocumentRequest for creating a new document
type CreateDocumentRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
CreatedByID uint `json:"created_by_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
ResidenceID uint `json:"residence_id" validate:"required"`
CreatedByID uint `json:"created_by_id" validate:"required"`
Title string `json:"title" validate:"required,max=200"`
Description string `json:"description"`
DocumentType string `json:"document_type" binding:"omitempty,oneof=general warranty receipt contract insurance manual"`
FileURL string `json:"file_url" binding:"max=500"`
FileName string `json:"file_name" binding:"max=255"`
DocumentType string `json:"document_type" validate:"omitempty,oneof=general warranty receipt contract insurance manual"`
FileURL string `json:"file_url" validate:"max=500"`
FileName string `json:"file_name" validate:"max=255"`
FileSize *int64 `json:"file_size"`
MimeType string `json:"mime_type" binding:"max=100"`
MimeType string `json:"mime_type" validate:"max=100"`
PurchaseDate *string `json:"purchase_date"`
ExpiryDate *string `json:"expiry_date"`
PurchasePrice *float64 `json:"purchase_price"`
Vendor string `json:"vendor" binding:"max=200"`
SerialNumber string `json:"serial_number" binding:"max=100"`
ModelNumber string `json:"model_number" binding:"max=100"`
Vendor string `json:"vendor" validate:"max=200"`
SerialNumber string `json:"serial_number" validate:"max=100"`
ModelNumber string `json:"model_number" validate:"max=100"`
TaskID *uint `json:"task_id"`
}
// SendTestNotificationRequest for sending a test push notification
type SendTestNotificationRequest struct {
UserID uint `json:"user_id" binding:"required"`
Title string `json:"title" binding:"required,max=200"`
Body string `json:"body" binding:"required,max=500"`
UserID uint `json:"user_id" validate:"required"`
Title string `json:"title" validate:"required,max=200"`
Body string `json:"body" validate:"required,max=500"`
}
// SendTestEmailRequest for sending a test email
type SendTestEmailRequest struct {
UserID uint `json:"user_id" binding:"required"`
Subject string `json:"subject" binding:"required,max=200"`
Body string `json:"body" binding:"required"`
UserID uint `json:"user_id" validate:"required"`
Subject string `json:"subject" validate:"required,max=200"`
Body string `json:"body" validate:"required"`
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -52,11 +52,10 @@ type UpdateAdminUserRequest struct {
}
// List handles GET /api/admin/admin-users
func (h *AdminUserManagementHandler) List(c *gin.Context) {
func (h *AdminUserManagementHandler) List(c echo.Context) error {
var filters AdminUserFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var adminUsers []models.AdminUser
@@ -92,8 +91,7 @@ func (h *AdminUserManagementHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&adminUsers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin users"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch admin users"})
}
responses := make([]AdminUserResponse, len(adminUsers))
@@ -101,56 +99,49 @@ func (h *AdminUserManagementHandler) List(c *gin.Context) {
responses[i] = h.toAdminUserResponse(&u)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Get(c *gin.Context) {
func (h *AdminUserManagementHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid admin user ID"})
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Admin user not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch admin user"})
}
c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
return c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
}
// Create handles POST /api/admin/admin-users
func (h *AdminUserManagementHandler) Create(c *gin.Context) {
func (h *AdminUserManagementHandler) Create(c echo.Context) error {
// Only super admins can create admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
currentAdmin := c.Get(middleware.AdminUserKey)
if currentAdmin == nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Unauthorized"})
}
admin := currentAdmin.(*models.AdminUser)
if !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can create admin users"})
return
return c.JSON(http.StatusForbidden, map[string]interface{}{"error": "Only super admins can create admin users"})
}
var req CreateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Check if email already exists
var existingCount int64
h.db.Model(&models.AdminUser{}).Where("email = ?", req.Email).Count(&existingCount)
if existingCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Email already exists"})
}
adminUser := models.AdminUser{
@@ -169,54 +160,46 @@ func (h *AdminUserManagementHandler) Create(c *gin.Context) {
}
if err := adminUser.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
if err := h.db.Create(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create admin user"})
}
c.JSON(http.StatusCreated, h.toAdminUserResponse(&adminUser))
return c.JSON(http.StatusCreated, h.toAdminUserResponse(&adminUser))
}
// Update handles PUT /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Update(c *gin.Context) {
func (h *AdminUserManagementHandler) Update(c echo.Context) error {
// Only super admins can update admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
currentAdmin := c.Get(middleware.AdminUserKey)
if currentAdmin == nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Unauthorized"})
}
admin := currentAdmin.(*models.AdminUser)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid admin user ID"})
}
// Allow users to update themselves, but only super admins can update others
if uint(id) != admin.ID && !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can update other admin users"})
return
return c.JSON(http.StatusForbidden, map[string]interface{}{"error": "Only super admins can update other admin users"})
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Admin user not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch admin user"})
}
var req UpdateAdminUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.Email != nil {
@@ -224,8 +207,7 @@ func (h *AdminUserManagementHandler) Update(c *gin.Context) {
var existingCount int64
h.db.Model(&models.AdminUser{}).Where("email = ? AND id != ?", *req.Email, id).Count(&existingCount)
if existingCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Email already exists"})
}
adminUser.Email = *req.Email
}
@@ -242,68 +224,58 @@ func (h *AdminUserManagementHandler) Update(c *gin.Context) {
if req.IsActive != nil && admin.IsSuperAdmin() {
// Prevent disabling yourself
if uint(id) == admin.ID && !*req.IsActive {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot deactivate your own account"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot deactivate your own account"})
}
adminUser.IsActive = *req.IsActive
}
if req.Password != nil {
if err := adminUser.SetPassword(*req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
}
if err := h.db.Save(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update admin user"})
}
c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
return c.JSON(http.StatusOK, h.toAdminUserResponse(&adminUser))
}
// Delete handles DELETE /api/admin/admin-users/:id
func (h *AdminUserManagementHandler) Delete(c *gin.Context) {
func (h *AdminUserManagementHandler) Delete(c echo.Context) error {
// Only super admins can delete admin users
currentAdmin, exists := c.Get(middleware.AdminUserKey)
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
currentAdmin := c.Get(middleware.AdminUserKey)
if currentAdmin == nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Unauthorized"})
}
admin := currentAdmin.(*models.AdminUser)
if !admin.IsSuperAdmin() {
c.JSON(http.StatusForbidden, gin.H{"error": "Only super admins can delete admin users"})
return
return c.JSON(http.StatusForbidden, map[string]interface{}{"error": "Only super admins can delete admin users"})
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid admin user ID"})
}
// Prevent self-deletion
if uint(id) == admin.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete your own account"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete your own account"})
}
var adminUser models.AdminUser
if err := h.db.First(&adminUser, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Admin user not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Admin user not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch admin user"})
}
if err := h.db.Delete(&adminUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete admin user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete admin user"})
}
c.JSON(http.StatusOK, gin.H{"message": "Admin user deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Admin user deleted successfully"})
}
func (h *AdminUserManagementHandler) toAdminUserResponse(u *models.AdminUser) AdminUserResponse {

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -41,11 +41,10 @@ type UpdateAppleSocialAuthRequest struct {
}
// List handles GET /api/admin/apple-social-auth
func (h *AdminAppleSocialAuthHandler) List(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var entries []models.AppleSocialAuth
@@ -75,8 +74,7 @@ func (h *AdminAppleSocialAuthHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&entries).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entries"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entries"})
}
// Build response
@@ -85,73 +83,63 @@ func (h *AdminAppleSocialAuthHandler) List(c *gin.Context) {
responses[i] = h.toResponse(&entry)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Get(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.Preload("User").First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
c.JSON(http.StatusOK, h.toResponse(&entry))
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// GetByUser handles GET /api/admin/apple-social-auth/user/:user_id
func (h *AdminAppleSocialAuthHandler) GetByUser(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var entry models.AppleSocialAuth
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&entry).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found for user"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found for user"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
c.JSON(http.StatusOK, h.toResponse(&entry))
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// Update handles PUT /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Update(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
var req UpdateAppleSocialAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.Email != nil {
@@ -162,54 +150,47 @@ func (h *AdminAppleSocialAuthHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update Apple social auth entry"})
}
h.db.Preload("User").First(&entry, id)
c.JSON(http.StatusOK, h.toResponse(&entry))
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// Delete handles DELETE /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Delete(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
if err := h.db.Delete(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entry"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entry"})
}
c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entry deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entry deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/apple-social-auth/bulk
func (h *AdminAppleSocialAuthHandler) BulkDelete(c *gin.Context) {
func (h *AdminAppleSocialAuthHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.AppleSocialAuth{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entries"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entries"})
}
c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entries deleted successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entries deleted successfully", "count": len(req.IDs)})
}
// toResponse converts an AppleSocialAuth model to AppleSocialAuthResponse

View File

@@ -3,7 +3,7 @@ package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/middleware"
@@ -68,37 +68,32 @@ func NewAdminUserResponse(admin *models.AdminUser) AdminUserResponse {
}
// Login handles POST /api/admin/auth/login
func (h *AdminAuthHandler) Login(c *gin.Context) {
func (h *AdminAuthHandler) Login(c echo.Context) error {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request: " + err.Error()})
}
// Find admin by email
admin, err := h.adminRepo.FindByEmail(req.Email)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Invalid email or password"})
}
// Check password
if !admin.CheckPassword(req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
return
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Invalid email or password"})
}
// Check if admin is active
if !admin.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Account is disabled"})
return
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Account is disabled"})
}
// Generate JWT token
token, err := middleware.GenerateAdminToken(admin, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to generate token"})
}
// Update last login
@@ -107,7 +102,7 @@ func (h *AdminAuthHandler) Login(c *gin.Context) {
// Refresh admin data after updating last login
admin, _ = h.adminRepo.FindByID(admin.ID)
c.JSON(http.StatusOK, LoginResponse{
return c.JSON(http.StatusOK, LoginResponse{
Token: token,
Admin: NewAdminUserResponse(admin),
})
@@ -115,26 +110,25 @@ func (h *AdminAuthHandler) Login(c *gin.Context) {
// Logout handles POST /api/admin/auth/logout
// Note: JWT tokens are stateless, so logout is handled client-side by removing the token
func (h *AdminAuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
func (h *AdminAuthHandler) Logout(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Logged out successfully"})
}
// Me handles GET /api/admin/auth/me
func (h *AdminAuthHandler) Me(c *gin.Context) {
admin := c.MustGet(middleware.AdminUserKey).(*models.AdminUser)
c.JSON(http.StatusOK, NewAdminUserResponse(admin))
func (h *AdminAuthHandler) Me(c echo.Context) error {
admin := c.Get(middleware.AdminUserKey).(*models.AdminUser)
return c.JSON(http.StatusOK, NewAdminUserResponse(admin))
}
// RefreshToken handles POST /api/admin/auth/refresh
func (h *AdminAuthHandler) RefreshToken(c *gin.Context) {
admin := c.MustGet(middleware.AdminUserKey).(*models.AdminUser)
func (h *AdminAuthHandler) RefreshToken(c echo.Context) error {
admin := c.Get(middleware.AdminUserKey).(*models.AdminUser)
// Generate new token
token, err := middleware.GenerateAdminToken(admin, h.cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to generate token"})
}
c.JSON(http.StatusOK, gin.H{"token": token})
return c.JSON(http.StatusOK, map[string]interface{}{"token": token})
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -31,11 +31,10 @@ type AuthTokenResponse struct {
}
// List handles GET /api/admin/auth-tokens
func (h *AdminAuthTokenHandler) List(c *gin.Context) {
func (h *AdminAuthTokenHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var tokens []models.AuthToken
@@ -67,8 +66,7 @@ func (h *AdminAuthTokenHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&tokens).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch auth tokens"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth tokens"})
}
// Build response
@@ -83,25 +81,22 @@ func (h *AdminAuthTokenHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/auth-tokens/:id (id is actually user_id)
func (h *AdminAuthTokenHandler) Get(c *gin.Context) {
func (h *AdminAuthTokenHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var token models.AuthToken
if err := h.db.Preload("User").Where("user_id = ?", id).First(&token).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Auth token not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch auth token"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth token"})
}
response := AuthTokenResponse{
@@ -112,44 +107,39 @@ func (h *AdminAuthTokenHandler) Get(c *gin.Context) {
Created: token.Created.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/auth-tokens/:id (revoke token)
func (h *AdminAuthTokenHandler) Delete(c *gin.Context) {
func (h *AdminAuthTokenHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
result := h.db.Where("user_id = ?", id).Delete(&models.AuthToken{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke token"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Auth token not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Auth token revoked successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth token revoked successfully"})
}
// BulkDelete handles DELETE /api/admin/auth-tokens/bulk
func (h *AdminAuthTokenHandler) BulkDelete(c *gin.Context) {
func (h *AdminAuthTokenHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("user_id IN ?", req.IDs).Delete(&models.AuthToken{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke tokens"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke tokens"})
}
c.JSON(http.StatusOK, gin.H{"message": "Auth tokens revoked successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth tokens revoked successfully", "count": result.RowsAffected})
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -55,11 +55,10 @@ type CompletionFilters struct {
}
// List handles GET /api/admin/completions
func (h *AdminCompletionHandler) List(c *gin.Context) {
func (h *AdminCompletionHandler) List(c echo.Context) error {
var filters CompletionFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var completions []models.TaskCompletion
@@ -112,8 +111,7 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&completions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completions"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completions"})
}
// Build response
@@ -122,71 +120,62 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
responses[i] = h.toCompletionResponse(&completion)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/completions/:id
func (h *AdminCompletionHandler) Get(c *gin.Context) {
func (h *AdminCompletionHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
}
var completion models.TaskCompletion
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
}
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
return c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
}
// Delete handles DELETE /api/admin/completions/:id
func (h *AdminCompletionHandler) Delete(c *gin.Context) {
func (h *AdminCompletionHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
}
var completion models.TaskCompletion
if err := h.db.First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
}
if err := h.db.Delete(&completion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completion"})
}
c.JSON(http.StatusOK, gin.H{"message": "Completion deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completion deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/completions/bulk
func (h *AdminCompletionHandler) BulkDelete(c *gin.Context) {
func (h *AdminCompletionHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.TaskCompletion{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completions"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completions"})
}
c.JSON(http.StatusOK, gin.H{"message": "Completions deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completions deleted successfully", "count": result.RowsAffected})
}
// UpdateCompletionRequest represents the request to update a completion
@@ -196,27 +185,23 @@ type UpdateCompletionRequest struct {
}
// Update handles PUT /api/admin/completions/:id
func (h *AdminCompletionHandler) Update(c *gin.Context) {
func (h *AdminCompletionHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid completion ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid completion ID"})
}
var completion models.TaskCompletion
if err := h.db.First(&completion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion"})
}
var req UpdateCompletionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.Notes != nil {
@@ -234,12 +219,11 @@ func (h *AdminCompletionHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&completion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update completion"})
}
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id)
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
return c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
}
// toCompletionResponse converts a TaskCompletion model to CompletionResponse

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -47,15 +47,14 @@ type UpdateCompletionImageRequest struct {
}
// List handles GET /api/admin/completion-images
func (h *AdminCompletionImageHandler) List(c *gin.Context) {
func (h *AdminCompletionImageHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Optional completion_id filter
completionIDStr := c.Query("completion_id")
completionIDStr := c.QueryParam("completion_id")
var images []models.TaskCompletionImage
var total int64
@@ -90,8 +89,7 @@ func (h *AdminCompletionImageHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&images).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion images"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion images"})
}
// Build response with task info
@@ -100,47 +98,41 @@ func (h *AdminCompletionImageHandler) List(c *gin.Context) {
responses[i] = h.toResponse(&image)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/completion-images/:id
func (h *AdminCompletionImageHandler) Get(c *gin.Context) {
func (h *AdminCompletionImageHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.TaskCompletionImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion image"})
}
c.JSON(http.StatusOK, h.toResponse(&image))
return c.JSON(http.StatusOK, h.toResponse(&image))
}
// Create handles POST /api/admin/completion-images
func (h *AdminCompletionImageHandler) Create(c *gin.Context) {
func (h *AdminCompletionImageHandler) Create(c echo.Context) error {
var req CreateCompletionImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify completion exists
var completion models.TaskCompletion
if err := h.db.First(&completion, req.CompletionID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusBadRequest, gin.H{"error": "Task completion not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Task completion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify completion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to verify completion"})
}
image := models.TaskCompletionImage{
@@ -150,35 +142,30 @@ func (h *AdminCompletionImageHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create completion image"})
}
c.JSON(http.StatusCreated, h.toResponse(&image))
return c.JSON(http.StatusCreated, h.toResponse(&image))
}
// Update handles PUT /api/admin/completion-images/:id
func (h *AdminCompletionImageHandler) Update(c *gin.Context) {
func (h *AdminCompletionImageHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.TaskCompletionImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion image"})
}
var req UpdateCompletionImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.ImageURL != nil {
@@ -189,53 +176,46 @@ func (h *AdminCompletionImageHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update completion image"})
}
c.JSON(http.StatusOK, h.toResponse(&image))
return c.JSON(http.StatusOK, h.toResponse(&image))
}
// Delete handles DELETE /api/admin/completion-images/:id
func (h *AdminCompletionImageHandler) Delete(c *gin.Context) {
func (h *AdminCompletionImageHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.TaskCompletionImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Completion image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch completion image"})
}
if err := h.db.Delete(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completion image"})
}
c.JSON(http.StatusOK, gin.H{"message": "Completion image deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completion image deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/completion-images/bulk
func (h *AdminCompletionImageHandler) BulkDelete(c *gin.Context) {
func (h *AdminCompletionImageHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.TaskCompletionImage{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion images"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete completion images"})
}
c.JSON(http.StatusOK, gin.H{"message": "Completion images deleted successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Completion images deleted successfully", "count": len(req.IDs)})
}
// toResponse converts a TaskCompletionImage model to AdminCompletionImageResponse

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -34,11 +34,10 @@ type ConfirmationCodeResponse struct {
}
// List handles GET /api/admin/confirmation-codes
func (h *AdminConfirmationCodeHandler) List(c *gin.Context) {
func (h *AdminConfirmationCodeHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var codes []models.ConfirmationCode
@@ -70,8 +69,7 @@ func (h *AdminConfirmationCodeHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&codes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation codes"})
}
// Build response
@@ -89,25 +87,22 @@ func (h *AdminConfirmationCodeHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/confirmation-codes/:id
func (h *AdminConfirmationCodeHandler) Get(c *gin.Context) {
func (h *AdminConfirmationCodeHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.ConfirmationCode
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation code"})
}
response := ConfirmationCodeResponse{
@@ -121,44 +116,39 @@ func (h *AdminConfirmationCodeHandler) Get(c *gin.Context) {
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/confirmation-codes/:id
func (h *AdminConfirmationCodeHandler) Delete(c *gin.Context) {
func (h *AdminConfirmationCodeHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.ConfirmationCode{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation code"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Confirmation code deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation code deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/confirmation-codes/bulk
func (h *AdminConfirmationCodeHandler) BulkDelete(c *gin.Context) {
func (h *AdminConfirmationCodeHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ConfirmationCode{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes"})
}
c.JSON(http.StatusOK, gin.H{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected})
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -22,11 +22,10 @@ func NewAdminContractorHandler(db *gorm.DB) *AdminContractorHandler {
}
// List handles GET /api/admin/contractors
func (h *AdminContractorHandler) List(c *gin.Context) {
func (h *AdminContractorHandler) List(c echo.Context) error {
var filters dto.ContractorFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var contractors []models.Contractor
@@ -71,8 +70,7 @@ func (h *AdminContractorHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&contractors).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractors"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch contractors"})
}
// Build response
@@ -81,15 +79,14 @@ func (h *AdminContractorHandler) List(c *gin.Context) {
responses[i] = h.toContractorResponse(&contractor)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/contractors/:id
func (h *AdminContractorHandler) Get(c *gin.Context) {
func (h *AdminContractorHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid contractor ID"})
}
var contractor models.Contractor
@@ -99,11 +96,9 @@ func (h *AdminContractorHandler) Get(c *gin.Context) {
Preload("Specialties").
First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Contractor not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch contractor"})
}
response := dto.ContractorDetailResponse{
@@ -115,39 +110,34 @@ func (h *AdminContractorHandler) Get(c *gin.Context) {
h.db.Model(&models.Task{}).Where("contractor_id = ?", contractor.ID).Count(&taskCount)
response.TaskCount = int(taskCount)
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/contractors/:id
func (h *AdminContractorHandler) Update(c *gin.Context) {
func (h *AdminContractorHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid contractor ID"})
}
var contractor models.Contractor
if err := h.db.First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Contractor not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch contractor"})
}
var req dto.UpdateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence if changing
if req.ResidenceID != nil {
var residence models.Residence
if err := h.db.First(&residence, *req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
contractor.ResidenceID = req.ResidenceID
}
@@ -155,8 +145,7 @@ func (h *AdminContractorHandler) Update(c *gin.Context) {
if req.CreatedByID != nil {
var user models.User
if err := h.db.First(&user, *req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Created by user not found"})
}
contractor.CreatedByID = *req.CreatedByID
}
@@ -213,36 +202,32 @@ func (h *AdminContractorHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update contractor"})
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Specialties").First(&contractor, id)
c.JSON(http.StatusOK, h.toContractorResponse(&contractor))
return c.JSON(http.StatusOK, h.toContractorResponse(&contractor))
}
// Create handles POST /api/admin/contractors
func (h *AdminContractorHandler) Create(c *gin.Context) {
func (h *AdminContractorHandler) Create(c echo.Context) error {
var req dto.CreateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence exists if provided
if req.ResidenceID != nil {
var residence models.Residence
if err := h.db.First(&residence, *req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Creator user not found"})
}
contractor := models.Contractor{
@@ -263,8 +248,7 @@ func (h *AdminContractorHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create contractor"})
}
// Add specialties if provided
@@ -275,52 +259,46 @@ func (h *AdminContractorHandler) Create(c *gin.Context) {
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Specialties").First(&contractor, contractor.ID)
c.JSON(http.StatusCreated, h.toContractorResponse(&contractor))
return c.JSON(http.StatusCreated, h.toContractorResponse(&contractor))
}
// Delete handles DELETE /api/admin/contractors/:id
func (h *AdminContractorHandler) Delete(c *gin.Context) {
func (h *AdminContractorHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid contractor ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid contractor ID"})
}
var contractor models.Contractor
if err := h.db.First(&contractor, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contractor not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Contractor not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch contractor"})
}
// Soft delete
contractor.IsActive = false
if err := h.db.Save(&contractor).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractor"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractor"})
}
c.JSON(http.StatusOK, gin.H{"message": "Contractor deactivated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Contractor deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/contractors/bulk
func (h *AdminContractorHandler) BulkDelete(c *gin.Context) {
func (h *AdminContractorHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Contractor{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractors"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractors"})
}
c.JSON(http.StatusOK, gin.H{"message": "Contractors deactivated successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Contractors deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminContractorHandler) toContractorResponse(contractor *models.Contractor) dto.ContractorResponse {

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
@@ -93,7 +93,7 @@ type SubscriptionStats struct {
}
// GetStats handles GET /api/admin/dashboard/stats
func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
func (h *AdminDashboardHandler) GetStats(c echo.Context) error {
stats := DashboardStats{}
now := time.Now()
thirtyDaysAgo := now.AddDate(0, 0, -30)
@@ -164,5 +164,5 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "premium").Count(&stats.Subscriptions.Premium)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "pro").Count(&stats.Subscriptions.Pro)
c.JSON(http.StatusOK, stats)
return c.JSON(http.StatusOK, stats)
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -47,11 +47,10 @@ type GCMDeviceResponse struct {
}
// ListAPNS handles GET /api/admin/devices/apns
func (h *AdminDeviceHandler) ListAPNS(c *gin.Context) {
func (h *AdminDeviceHandler) ListAPNS(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var devices []models.APNSDevice
@@ -79,8 +78,7 @@ func (h *AdminDeviceHandler) ListAPNS(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&devices).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch devices"})
}
responses := make([]APNSDeviceResponse, len(devices))
@@ -101,15 +99,14 @@ func (h *AdminDeviceHandler) ListAPNS(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// ListGCM handles GET /api/admin/devices/gcm
func (h *AdminDeviceHandler) ListGCM(c *gin.Context) {
func (h *AdminDeviceHandler) ListGCM(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var devices []models.GCMDevice
@@ -136,8 +133,7 @@ func (h *AdminDeviceHandler) ListGCM(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&devices).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch devices"})
}
responses := make([]GCMDeviceResponse, len(devices))
@@ -159,159 +155,139 @@ func (h *AdminDeviceHandler) ListGCM(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// UpdateAPNS handles PUT /api/admin/devices/apns/:id
func (h *AdminDeviceHandler) UpdateAPNS(c *gin.Context) {
func (h *AdminDeviceHandler) UpdateAPNS(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var device models.APNSDevice
if err := h.db.First(&device, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Device not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch device"})
}
var req struct {
Active bool `json:"active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
device.Active = req.Active
if err := h.db.Save(&device).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update device"})
}
c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Device updated successfully"})
}
// UpdateGCM handles PUT /api/admin/devices/gcm/:id
func (h *AdminDeviceHandler) UpdateGCM(c *gin.Context) {
func (h *AdminDeviceHandler) UpdateGCM(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var device models.GCMDevice
if err := h.db.First(&device, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Device not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch device"})
}
var req struct {
Active bool `json:"active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
device.Active = req.Active
if err := h.db.Save(&device).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update device"})
}
c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Device updated successfully"})
}
// DeleteAPNS handles DELETE /api/admin/devices/apns/:id
func (h *AdminDeviceHandler) DeleteAPNS(c *gin.Context) {
func (h *AdminDeviceHandler) DeleteAPNS(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.APNSDevice{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete device"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Device not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Device deleted successfully"})
}
// DeleteGCM handles DELETE /api/admin/devices/gcm/:id
func (h *AdminDeviceHandler) DeleteGCM(c *gin.Context) {
func (h *AdminDeviceHandler) DeleteGCM(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.GCMDevice{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete device"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Device not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Device deleted successfully"})
}
// BulkDeleteAPNS handles DELETE /api/admin/devices/apns/bulk
func (h *AdminDeviceHandler) BulkDeleteAPNS(c *gin.Context) {
func (h *AdminDeviceHandler) BulkDeleteAPNS(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.APNSDevice{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete devices"})
}
c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Devices deleted successfully", "count": result.RowsAffected})
}
// BulkDeleteGCM handles DELETE /api/admin/devices/gcm/bulk
func (h *AdminDeviceHandler) BulkDeleteGCM(c *gin.Context) {
func (h *AdminDeviceHandler) BulkDeleteGCM(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.GCMDevice{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete devices"})
}
c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Devices deleted successfully", "count": result.RowsAffected})
}
// GetStats handles GET /api/admin/devices/stats
func (h *AdminDeviceHandler) GetStats(c *gin.Context) {
func (h *AdminDeviceHandler) GetStats(c echo.Context) error {
var apnsTotal, apnsActive, gcmTotal, gcmActive int64
h.db.Model(&models.APNSDevice{}).Count(&apnsTotal)
@@ -319,12 +295,12 @@ func (h *AdminDeviceHandler) GetStats(c *gin.Context) {
h.db.Model(&models.GCMDevice{}).Count(&gcmTotal)
h.db.Model(&models.GCMDevice{}).Where("active = ?", true).Count(&gcmActive)
c.JSON(http.StatusOK, gin.H{
"apns": gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"apns": map[string]interface{}{
"total": apnsTotal,
"active": apnsActive,
},
"gcm": gin.H{
"gcm": map[string]interface{}{
"total": gcmTotal,
"active": gcmActive,
},

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -24,11 +24,10 @@ func NewAdminDocumentHandler(db *gorm.DB) *AdminDocumentHandler {
}
// List handles GET /api/admin/documents
func (h *AdminDocumentHandler) List(c *gin.Context) {
func (h *AdminDocumentHandler) List(c echo.Context) error {
var filters dto.DocumentFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var documents []models.Document
@@ -73,8 +72,7 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&documents).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch documents"})
}
// Build response
@@ -83,15 +81,14 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
responses[i] = h.toDocumentResponse(&doc)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/documents/:id
func (h *AdminDocumentHandler) Get(c *gin.Context) {
func (h *AdminDocumentHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid document ID"})
}
var document models.Document
@@ -102,11 +99,9 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
Preload("Images").
First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document"})
}
response := dto.DocumentDetailResponse{
@@ -117,39 +112,34 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
response.TaskTitle = &document.Task.Title
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/documents/:id
func (h *AdminDocumentHandler) Update(c *gin.Context) {
func (h *AdminDocumentHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid document ID"})
}
var document models.Document
if err := h.db.First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document"})
}
var req dto.UpdateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence if changing
if req.ResidenceID != nil {
var residence models.Residence
if err := h.db.First(&residence, *req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
document.ResidenceID = *req.ResidenceID
}
@@ -157,8 +147,7 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
if req.CreatedByID != nil {
var user models.User
if err := h.db.First(&user, *req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Created by user not found"})
}
document.CreatedByID = *req.CreatedByID
}
@@ -232,34 +221,30 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update document"})
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, id)
c.JSON(http.StatusOK, h.toDocumentResponse(&document))
return c.JSON(http.StatusOK, h.toDocumentResponse(&document))
}
// Create handles POST /api/admin/documents
func (h *AdminDocumentHandler) Create(c *gin.Context) {
func (h *AdminDocumentHandler) Create(c echo.Context) error {
var req dto.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence exists
var residence models.Residence
if err := h.db.First(&residence, req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Creator user not found"})
}
documentType := models.DocumentTypeGeneral
@@ -302,57 +287,50 @@ func (h *AdminDocumentHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create document"})
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, document.ID)
c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
return c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
}
// Delete handles DELETE /api/admin/documents/:id
func (h *AdminDocumentHandler) Delete(c *gin.Context) {
func (h *AdminDocumentHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid document ID"})
}
var document models.Document
if err := h.db.First(&document, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document"})
}
// Soft delete
document.IsActive = false
if err := h.db.Save(&document).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete document"})
}
c.JSON(http.StatusOK, gin.H{"message": "Document deactivated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/documents/bulk
func (h *AdminDocumentHandler) BulkDelete(c *gin.Context) {
func (h *AdminDocumentHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Document{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete documents"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete documents"})
}
c.JSON(http.StatusOK, gin.H{"message": "Documents deactivated successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Documents deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.DocumentResponse {

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -48,15 +48,14 @@ type UpdateDocumentImageRequest struct {
}
// List handles GET /api/admin/document-images
func (h *AdminDocumentImageHandler) List(c *gin.Context) {
func (h *AdminDocumentImageHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Optional document_id filter
documentIDStr := c.Query("document_id")
documentIDStr := c.QueryParam("document_id")
var images []models.DocumentImage
var total int64
@@ -91,8 +90,7 @@ func (h *AdminDocumentImageHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&images).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document images"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document images"})
}
// Build response with document info
@@ -101,47 +99,41 @@ func (h *AdminDocumentImageHandler) List(c *gin.Context) {
responses[i] = h.toResponse(&image)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/document-images/:id
func (h *AdminDocumentImageHandler) Get(c *gin.Context) {
func (h *AdminDocumentImageHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.DocumentImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document image"})
}
c.JSON(http.StatusOK, h.toResponse(&image))
return c.JSON(http.StatusOK, h.toResponse(&image))
}
// Create handles POST /api/admin/document-images
func (h *AdminDocumentImageHandler) Create(c *gin.Context) {
func (h *AdminDocumentImageHandler) Create(c echo.Context) error {
var req CreateDocumentImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify document exists
var document models.Document
if err := h.db.First(&document, req.DocumentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusBadRequest, gin.H{"error": "Document not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Document not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify document"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to verify document"})
}
image := models.DocumentImage{
@@ -151,35 +143,30 @@ func (h *AdminDocumentImageHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create document image"})
}
c.JSON(http.StatusCreated, h.toResponse(&image))
return c.JSON(http.StatusCreated, h.toResponse(&image))
}
// Update handles PUT /api/admin/document-images/:id
func (h *AdminDocumentImageHandler) Update(c *gin.Context) {
func (h *AdminDocumentImageHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.DocumentImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document image"})
}
var req UpdateDocumentImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.ImageURL != nil {
@@ -190,53 +177,46 @@ func (h *AdminDocumentImageHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update document image"})
}
c.JSON(http.StatusOK, h.toResponse(&image))
return c.JSON(http.StatusOK, h.toResponse(&image))
}
// Delete handles DELETE /api/admin/document-images/:id
func (h *AdminDocumentImageHandler) Delete(c *gin.Context) {
func (h *AdminDocumentImageHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid image ID"})
}
var image models.DocumentImage
if err := h.db.First(&image, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Document image not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch document image"})
}
if err := h.db.Delete(&image).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document image"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete document image"})
}
c.JSON(http.StatusOK, gin.H{"message": "Document image deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document image deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/document-images/bulk
func (h *AdminDocumentImageHandler) BulkDelete(c *gin.Context) {
func (h *AdminDocumentImageHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.DocumentImage{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document images"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete document images"})
}
c.JSON(http.StatusOK, gin.H{"message": "Document images deleted successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document images deleted successfully", "count": len(req.IDs)})
}
// toResponse converts a DocumentImage model to DocumentImageResponse

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -34,11 +34,10 @@ type FeatureBenefitResponse struct {
}
// List handles GET /api/admin/feature-benefits
func (h *AdminFeatureBenefitHandler) List(c *gin.Context) {
func (h *AdminFeatureBenefitHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var benefits []models.FeatureBenefit
@@ -61,8 +60,7 @@ func (h *AdminFeatureBenefitHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&benefits).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefits"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch feature benefits"})
}
responses := make([]FeatureBenefitResponse, len(benefits))
@@ -79,25 +77,22 @@ func (h *AdminFeatureBenefitHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/feature-benefits/:id
func (h *AdminFeatureBenefitHandler) Get(c *gin.Context) {
func (h *AdminFeatureBenefitHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var benefit models.FeatureBenefit
if err := h.db.First(&benefit, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Feature benefit not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch feature benefit"})
}
response := FeatureBenefitResponse{
@@ -111,11 +106,11 @@ func (h *AdminFeatureBenefitHandler) Get(c *gin.Context) {
UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Create handles POST /api/admin/feature-benefits
func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) {
func (h *AdminFeatureBenefitHandler) Create(c echo.Context) error {
var req struct {
FeatureName string `json:"feature_name" binding:"required"`
FreeTierText string `json:"free_tier_text" binding:"required"`
@@ -124,9 +119,8 @@ func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
benefit := models.FeatureBenefit{
@@ -142,11 +136,10 @@ func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&benefit).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create feature benefit"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create feature benefit"})
}
c.JSON(http.StatusCreated, FeatureBenefitResponse{
return c.JSON(http.StatusCreated, FeatureBenefitResponse{
ID: benefit.ID,
FeatureName: benefit.FeatureName,
FreeTierText: benefit.FreeTierText,
@@ -159,21 +152,18 @@ func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) {
}
// Update handles PUT /api/admin/feature-benefits/:id
func (h *AdminFeatureBenefitHandler) Update(c *gin.Context) {
func (h *AdminFeatureBenefitHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var benefit models.FeatureBenefit
if err := h.db.First(&benefit, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Feature benefit not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch feature benefit"})
}
var req struct {
@@ -184,9 +174,8 @@ func (h *AdminFeatureBenefitHandler) Update(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.FeatureName != nil {
@@ -206,11 +195,10 @@ func (h *AdminFeatureBenefitHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&benefit).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update feature benefit"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update feature benefit"})
}
c.JSON(http.StatusOK, FeatureBenefitResponse{
return c.JSON(http.StatusOK, FeatureBenefitResponse{
ID: benefit.ID,
FeatureName: benefit.FeatureName,
FreeTierText: benefit.FreeTierText,
@@ -223,23 +211,20 @@ func (h *AdminFeatureBenefitHandler) Update(c *gin.Context) {
}
// Delete handles DELETE /api/admin/feature-benefits/:id
func (h *AdminFeatureBenefitHandler) Delete(c *gin.Context) {
func (h *AdminFeatureBenefitHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.FeatureBenefit{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete feature benefit"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete feature benefit"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Feature benefit not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Feature benefit deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Feature benefit deleted successfully"})
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
@@ -28,7 +28,7 @@ type LimitationsSettingsResponse struct {
}
// GetSettings handles GET /api/admin/limitations/settings
func (h *AdminLimitationsHandler) GetSettings(c *gin.Context) {
func (h *AdminLimitationsHandler) GetSettings(c echo.Context) error {
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -36,12 +36,11 @@ func (h *AdminLimitationsHandler) GetSettings(c *gin.Context) {
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
h.db.Create(&settings)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
c.JSON(http.StatusOK, LimitationsSettingsResponse{
return c.JSON(http.StatusOK, LimitationsSettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
@@ -52,11 +51,10 @@ type UpdateLimitationsSettingsRequest struct {
}
// UpdateSettings handles PUT /api/admin/limitations/settings
func (h *AdminLimitationsHandler) UpdateSettings(c *gin.Context) {
func (h *AdminLimitationsHandler) UpdateSettings(c echo.Context) error {
var req UpdateLimitationsSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var settings models.SubscriptionSettings
@@ -64,8 +62,7 @@ func (h *AdminLimitationsHandler) UpdateSettings(c *gin.Context) {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{ID: 1}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
@@ -74,11 +71,10 @@ func (h *AdminLimitationsHandler) UpdateSettings(c *gin.Context) {
}
if err := h.db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update settings"})
}
c.JSON(http.StatusOK, LimitationsSettingsResponse{
return c.JSON(http.StatusOK, LimitationsSettingsResponse{
EnableLimitations: settings.EnableLimitations,
})
}
@@ -111,11 +107,10 @@ func toTierLimitsResponse(t *models.TierLimits) TierLimitsResponse {
}
// ListTierLimits handles GET /api/admin/limitations/tier-limits
func (h *AdminLimitationsHandler) ListTierLimits(c *gin.Context) {
func (h *AdminLimitationsHandler) ListTierLimits(c echo.Context) error {
var limits []models.TierLimits
if err := h.db.Order("tier").Find(&limits).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch tier limits"})
}
// If no limits exist, create defaults
@@ -132,18 +127,17 @@ func (h *AdminLimitationsHandler) ListTierLimits(c *gin.Context) {
responses[i] = toTierLimitsResponse(&l)
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"data": responses,
"total": len(responses),
})
}
// GetTierLimits handles GET /api/admin/limitations/tier-limits/:tier
func (h *AdminLimitationsHandler) GetTierLimits(c *gin.Context) {
func (h *AdminLimitationsHandler) GetTierLimits(c echo.Context) error {
tier := c.Param("tier")
if tier != "free" && tier != "pro" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid tier. Must be 'free' or 'pro'"})
}
var limits models.TierLimits
@@ -157,12 +151,11 @@ func (h *AdminLimitationsHandler) GetTierLimits(c *gin.Context) {
}
h.db.Create(&limits)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch tier limits"})
}
}
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
return c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
}
// UpdateTierLimitsRequest represents the update request for tier limits
@@ -174,17 +167,15 @@ type UpdateTierLimitsRequest struct {
}
// UpdateTierLimits handles PUT /api/admin/limitations/tier-limits/:tier
func (h *AdminLimitationsHandler) UpdateTierLimits(c *gin.Context) {
func (h *AdminLimitationsHandler) UpdateTierLimits(c echo.Context) error {
tier := c.Param("tier")
if tier != "free" && tier != "pro" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid tier. Must be 'free' or 'pro'"})
}
var req UpdateTierLimitsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var limits models.TierLimits
@@ -193,8 +184,7 @@ func (h *AdminLimitationsHandler) UpdateTierLimits(c *gin.Context) {
// Create new entry
limits = models.TierLimits{Tier: models.SubscriptionTier(tier)}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch tier limits"})
}
}
@@ -207,11 +197,10 @@ func (h *AdminLimitationsHandler) UpdateTierLimits(c *gin.Context) {
limits.DocumentsLimit = req.DocumentsLimit
if err := h.db.Save(&limits).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update tier limits"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update tier limits"})
}
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
return c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
}
// === Upgrade Triggers ===
@@ -253,7 +242,7 @@ var availableTriggerKeys = []string{
}
// GetAvailableTriggerKeys handles GET /api/admin/limitations/upgrade-triggers/keys
func (h *AdminLimitationsHandler) GetAvailableTriggerKeys(c *gin.Context) {
func (h *AdminLimitationsHandler) GetAvailableTriggerKeys(c echo.Context) error {
type KeyOption struct {
Key string `json:"key"`
Label string `json:"label"`
@@ -267,15 +256,14 @@ func (h *AdminLimitationsHandler) GetAvailableTriggerKeys(c *gin.Context) {
{Key: "view_documents", Label: "View Documents & Warranties"},
}
c.JSON(http.StatusOK, keys)
return c.JSON(http.StatusOK, keys)
}
// ListUpgradeTriggers handles GET /api/admin/limitations/upgrade-triggers
func (h *AdminLimitationsHandler) ListUpgradeTriggers(c *gin.Context) {
func (h *AdminLimitationsHandler) ListUpgradeTriggers(c echo.Context) error {
var triggers []models.UpgradeTrigger
if err := h.db.Order("trigger_key").Find(&triggers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade triggers"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch upgrade triggers"})
}
responses := make([]UpgradeTriggerResponse, len(triggers))
@@ -283,31 +271,28 @@ func (h *AdminLimitationsHandler) ListUpgradeTriggers(c *gin.Context) {
responses[i] = toUpgradeTriggerResponse(&t)
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"data": responses,
"total": len(responses),
})
}
// GetUpgradeTrigger handles GET /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) GetUpgradeTrigger(c *gin.Context) {
func (h *AdminLimitationsHandler) GetUpgradeTrigger(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Upgrade trigger not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch upgrade trigger"})
}
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
return c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
}
// CreateUpgradeTriggerRequest represents the create request
@@ -321,11 +306,10 @@ type CreateUpgradeTriggerRequest struct {
}
// CreateUpgradeTrigger handles POST /api/admin/limitations/upgrade-triggers
func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c *gin.Context) {
func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c echo.Context) error {
var req CreateUpgradeTriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Validate trigger key
@@ -337,15 +321,13 @@ func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c *gin.Context) {
}
}
if !validKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid trigger_key"})
}
// Check if trigger key already exists
var existing models.UpgradeTrigger
if err := h.db.Where("trigger_key = ?", req.TriggerKey).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Trigger key already exists"})
}
trigger := models.UpgradeTrigger{
@@ -365,11 +347,10 @@ func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c *gin.Context) {
}
if err := h.db.Create(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create upgrade trigger"})
}
c.JSON(http.StatusCreated, toUpgradeTriggerResponse(&trigger))
return c.JSON(http.StatusCreated, toUpgradeTriggerResponse(&trigger))
}
// UpdateUpgradeTriggerRequest represents the update request
@@ -383,27 +364,23 @@ type UpdateUpgradeTriggerRequest struct {
}
// UpdateUpgradeTrigger handles PUT /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c *gin.Context) {
func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Upgrade trigger not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch upgrade trigger"})
}
var req UpdateUpgradeTriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.TriggerKey != nil {
@@ -416,15 +393,13 @@ func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c *gin.Context) {
}
}
if !validKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid trigger_key"})
}
// Check if key is already used by another trigger
if *req.TriggerKey != trigger.TriggerKey {
var existing models.UpgradeTrigger
if err := h.db.Where("trigger_key = ?", *req.TriggerKey).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Trigger key already exists"})
}
}
trigger.TriggerKey = *req.TriggerKey
@@ -446,35 +421,30 @@ func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c *gin.Context) {
}
if err := h.db.Save(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update upgrade trigger"})
}
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
return c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
}
// DeleteUpgradeTrigger handles DELETE /api/admin/limitations/upgrade-triggers/:id
func (h *AdminLimitationsHandler) DeleteUpgradeTrigger(c *gin.Context) {
func (h *AdminLimitationsHandler) DeleteUpgradeTrigger(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var trigger models.UpgradeTrigger
if err := h.db.First(&trigger, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Upgrade trigger not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch upgrade trigger"})
}
if err := h.db.Delete(&trigger).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete upgrade trigger"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete upgrade trigger"})
}
c.JSON(http.StatusOK, gin.H{"message": "Upgrade trigger deleted"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Upgrade trigger deleted"})
}

View File

@@ -5,7 +5,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -28,18 +28,15 @@ func NewAdminLookupHandler(db *gorm.DB) *AdminLookupHandler {
func (h *AdminLookupHandler) refreshCategoriesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch categories for cache refresh")
return
}
if err := cache.CacheCategories(ctx, categories); err != nil {
log.Warn().Err(err).Msg("Failed to cache categories")
return
}
log.Debug().Int("count", len(categories)).Msg("Refreshed categories cache")
@@ -51,18 +48,15 @@ func (h *AdminLookupHandler) refreshCategoriesCache(ctx context.Context) {
func (h *AdminLookupHandler) refreshPrioritiesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch priorities for cache refresh")
return
}
if err := cache.CachePriorities(ctx, priorities); err != nil {
log.Warn().Err(err).Msg("Failed to cache priorities")
return
}
log.Debug().Int("count", len(priorities)).Msg("Refreshed priorities cache")
@@ -74,18 +68,15 @@ func (h *AdminLookupHandler) refreshPrioritiesCache(ctx context.Context) {
func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch frequencies for cache refresh")
return
}
if err := cache.CacheFrequencies(ctx, frequencies); err != nil {
log.Warn().Err(err).Msg("Failed to cache frequencies")
return
}
log.Debug().Int("count", len(frequencies)).Msg("Refreshed frequencies cache")
@@ -97,18 +88,15 @@ func (h *AdminLookupHandler) refreshFrequenciesCache(ctx context.Context) {
func (h *AdminLookupHandler) refreshResidenceTypesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var types []models.ResidenceType
if err := h.db.Order("name ASC").Find(&types).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch residence types for cache refresh")
return
}
if err := cache.CacheResidenceTypes(ctx, types); err != nil {
log.Warn().Err(err).Msg("Failed to cache residence types")
return
}
log.Debug().Int("count", len(types)).Msg("Refreshed residence types cache")
@@ -120,18 +108,15 @@ func (h *AdminLookupHandler) refreshResidenceTypesCache(ctx context.Context) {
func (h *AdminLookupHandler) refreshSpecialtiesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch specialties for cache refresh")
return
}
if err := cache.CacheSpecialties(ctx, specialties); err != nil {
log.Warn().Err(err).Msg("Failed to cache specialties")
return
}
log.Debug().Int("count", len(specialties)).Msg("Refreshed specialties cache")
@@ -144,12 +129,10 @@ func (h *AdminLookupHandler) refreshSpecialtiesCache(ctx context.Context) {
func (h *AdminLookupHandler) invalidateSeededDataCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
if err := cache.InvalidateSeededData(ctx); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate seeded data cache")
return
}
log.Debug().Msg("Invalidated seeded data cache")
}
@@ -173,11 +156,10 @@ type CreateUpdateCategoryRequest struct {
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListCategories(c *gin.Context) {
func (h *AdminLookupHandler) ListCategories(c echo.Context) error {
var categories []models.TaskCategory
if err := h.db.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch categories"})
}
responses := make([]TaskCategoryResponse, len(categories))
@@ -192,14 +174,13 @@ func (h *AdminLookupHandler) ListCategories(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
func (h *AdminLookupHandler) CreateCategory(c echo.Context) error {
var req CreateUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
category := models.TaskCategory{
@@ -213,14 +194,13 @@ func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
}
if err := h.db.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create category"})
}
// Refresh cache after creating
h.refreshCategoriesCache(c.Request.Context())
h.refreshCategoriesCache(c.Request().Context())
c.JSON(http.StatusCreated, TaskCategoryResponse{
return c.JSON(http.StatusCreated, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
Description: category.Description,
@@ -230,27 +210,23 @@ func (h *AdminLookupHandler) CreateCategory(c *gin.Context) {
})
}
func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
func (h *AdminLookupHandler) UpdateCategory(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid category ID"})
}
var category models.TaskCategory
if err := h.db.First(&category, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Category not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch category"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch category"})
}
var req CreateUpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
category.Name = req.Name
@@ -262,14 +238,13 @@ func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
}
if err := h.db.Save(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update category"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update category"})
}
// Refresh cache after updating
h.refreshCategoriesCache(c.Request.Context())
h.refreshCategoriesCache(c.Request().Context())
c.JSON(http.StatusOK, TaskCategoryResponse{
return c.JSON(http.StatusOK, TaskCategoryResponse{
ID: category.ID,
Name: category.Name,
Description: category.Description,
@@ -279,30 +254,27 @@ func (h *AdminLookupHandler) UpdateCategory(c *gin.Context) {
})
}
func (h *AdminLookupHandler) DeleteCategory(c *gin.Context) {
func (h *AdminLookupHandler) DeleteCategory(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid category ID"})
}
// Check if category is in use
var count int64
h.db.Model(&models.Task{}).Where("category_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete category that is in use by tasks"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete category that is in use by tasks"})
}
if err := h.db.Delete(&models.TaskCategory{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete category"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete category"})
}
// Refresh cache after deleting
h.refreshCategoriesCache(c.Request.Context())
h.refreshCategoriesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Category deleted successfully"})
}
// ========== Task Priorities ==========
@@ -322,11 +294,10 @@ type CreateUpdatePriorityRequest struct {
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListPriorities(c *gin.Context) {
func (h *AdminLookupHandler) ListPriorities(c echo.Context) error {
var priorities []models.TaskPriority
if err := h.db.Order("display_order ASC, level ASC").Find(&priorities).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch priorities"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch priorities"})
}
responses := make([]TaskPriorityResponse, len(priorities))
@@ -340,14 +311,13 @@ func (h *AdminLookupHandler) ListPriorities(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
func (h *AdminLookupHandler) CreatePriority(c echo.Context) error {
var req CreateUpdatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
priority := models.TaskPriority{
@@ -360,14 +330,13 @@ func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
}
if err := h.db.Create(&priority).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create priority"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create priority"})
}
// Refresh cache after creating
h.refreshPrioritiesCache(c.Request.Context())
h.refreshPrioritiesCache(c.Request().Context())
c.JSON(http.StatusCreated, TaskPriorityResponse{
return c.JSON(http.StatusCreated, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
Level: priority.Level,
@@ -376,27 +345,23 @@ func (h *AdminLookupHandler) CreatePriority(c *gin.Context) {
})
}
func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
func (h *AdminLookupHandler) UpdatePriority(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid priority ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid priority ID"})
}
var priority models.TaskPriority
if err := h.db.First(&priority, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Priority not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Priority not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch priority"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch priority"})
}
var req CreateUpdatePriorityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
priority.Name = req.Name
@@ -407,14 +372,13 @@ func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
}
if err := h.db.Save(&priority).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update priority"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update priority"})
}
// Refresh cache after updating
h.refreshPrioritiesCache(c.Request.Context())
h.refreshPrioritiesCache(c.Request().Context())
c.JSON(http.StatusOK, TaskPriorityResponse{
return c.JSON(http.StatusOK, TaskPriorityResponse{
ID: priority.ID,
Name: priority.Name,
Level: priority.Level,
@@ -423,29 +387,26 @@ func (h *AdminLookupHandler) UpdatePriority(c *gin.Context) {
})
}
func (h *AdminLookupHandler) DeletePriority(c *gin.Context) {
func (h *AdminLookupHandler) DeletePriority(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid priority ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid priority ID"})
}
var count int64
h.db.Model(&models.Task{}).Where("priority_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete priority that is in use by tasks"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete priority that is in use by tasks"})
}
if err := h.db.Delete(&models.TaskPriority{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete priority"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete priority"})
}
// Refresh cache after deleting
h.refreshPrioritiesCache(c.Request.Context())
h.refreshPrioritiesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Priority deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Priority deleted successfully"})
}
// ========== Task Frequencies ==========
@@ -463,11 +424,10 @@ type CreateUpdateFrequencyRequest struct {
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListFrequencies(c *gin.Context) {
func (h *AdminLookupHandler) ListFrequencies(c echo.Context) error {
var frequencies []models.TaskFrequency
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch frequencies"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch frequencies"})
}
responses := make([]TaskFrequencyResponse, len(frequencies))
@@ -480,14 +440,13 @@ func (h *AdminLookupHandler) ListFrequencies(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
var req CreateUpdateFrequencyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
frequency := models.TaskFrequency{
@@ -499,14 +458,13 @@ func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
}
if err := h.db.Create(&frequency).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create frequency"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create frequency"})
}
// Refresh cache after creating
h.refreshFrequenciesCache(c.Request.Context())
h.refreshFrequenciesCache(c.Request().Context())
c.JSON(http.StatusCreated, TaskFrequencyResponse{
return c.JSON(http.StatusCreated, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
@@ -514,27 +472,23 @@ func (h *AdminLookupHandler) CreateFrequency(c *gin.Context) {
})
}
func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
func (h *AdminLookupHandler) UpdateFrequency(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid frequency ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid frequency ID"})
}
var frequency models.TaskFrequency
if err := h.db.First(&frequency, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Frequency not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Frequency not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch frequency"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch frequency"})
}
var req CreateUpdateFrequencyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
frequency.Name = req.Name
@@ -544,14 +498,13 @@ func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
}
if err := h.db.Save(&frequency).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update frequency"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update frequency"})
}
// Refresh cache after updating
h.refreshFrequenciesCache(c.Request.Context())
h.refreshFrequenciesCache(c.Request().Context())
c.JSON(http.StatusOK, TaskFrequencyResponse{
return c.JSON(http.StatusOK, TaskFrequencyResponse{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
@@ -559,29 +512,26 @@ func (h *AdminLookupHandler) UpdateFrequency(c *gin.Context) {
})
}
func (h *AdminLookupHandler) DeleteFrequency(c *gin.Context) {
func (h *AdminLookupHandler) DeleteFrequency(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid frequency ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid frequency ID"})
}
var count int64
h.db.Model(&models.Task{}).Where("frequency_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete frequency that is in use by tasks"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete frequency that is in use by tasks"})
}
if err := h.db.Delete(&models.TaskFrequency{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete frequency"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete frequency"})
}
// Refresh cache after deleting
h.refreshFrequenciesCache(c.Request.Context())
h.refreshFrequenciesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Frequency deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Frequency deleted successfully"})
}
// ========== Residence Types ==========
@@ -595,11 +545,10 @@ type CreateUpdateResidenceTypeRequest struct {
Name string `json:"name" binding:"required,max=20"`
}
func (h *AdminLookupHandler) ListResidenceTypes(c *gin.Context) {
func (h *AdminLookupHandler) ListResidenceTypes(c echo.Context) error {
var types []models.ResidenceType
if err := h.db.Order("name ASC").Find(&types).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence types"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residence types"})
}
responses := make([]ResidenceTypeResponse, len(types))
@@ -610,92 +559,82 @@ func (h *AdminLookupHandler) ListResidenceTypes(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateResidenceType(c *gin.Context) {
func (h *AdminLookupHandler) CreateResidenceType(c echo.Context) error {
var req CreateUpdateResidenceTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
residenceType := models.ResidenceType{Name: req.Name}
if err := h.db.Create(&residenceType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create residence type"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create residence type"})
}
// Refresh cache after creating
h.refreshResidenceTypesCache(c.Request.Context())
h.refreshResidenceTypesCache(c.Request().Context())
c.JSON(http.StatusCreated, ResidenceTypeResponse{
return c.JSON(http.StatusCreated, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
})
}
func (h *AdminLookupHandler) UpdateResidenceType(c *gin.Context) {
func (h *AdminLookupHandler) UpdateResidenceType(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence type ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid residence type ID"})
}
var residenceType models.ResidenceType
if err := h.db.First(&residenceType, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence type not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Residence type not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence type"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residence type"})
}
var req CreateUpdateResidenceTypeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
residenceType.Name = req.Name
if err := h.db.Save(&residenceType).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update residence type"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update residence type"})
}
// Refresh cache after updating
h.refreshResidenceTypesCache(c.Request.Context())
h.refreshResidenceTypesCache(c.Request().Context())
c.JSON(http.StatusOK, ResidenceTypeResponse{
return c.JSON(http.StatusOK, ResidenceTypeResponse{
ID: residenceType.ID,
Name: residenceType.Name,
})
}
func (h *AdminLookupHandler) DeleteResidenceType(c *gin.Context) {
func (h *AdminLookupHandler) DeleteResidenceType(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence type ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid residence type ID"})
}
var count int64
h.db.Model(&models.Residence{}).Where("property_type_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete residence type that is in use"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete residence type that is in use"})
}
if err := h.db.Delete(&models.ResidenceType{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence type"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence type"})
}
// Refresh cache after deleting
h.refreshResidenceTypesCache(c.Request.Context())
h.refreshResidenceTypesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Residence type deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Residence type deleted successfully"})
}
// ========== Contractor Specialties ==========
@@ -715,11 +654,10 @@ type CreateUpdateSpecialtyRequest struct {
DisplayOrder *int `json:"display_order"`
}
func (h *AdminLookupHandler) ListSpecialties(c *gin.Context) {
func (h *AdminLookupHandler) ListSpecialties(c echo.Context) error {
var specialties []models.ContractorSpecialty
if err := h.db.Order("display_order ASC, name ASC").Find(&specialties).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch specialties"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch specialties"})
}
responses := make([]ContractorSpecialtyResponse, len(specialties))
@@ -733,14 +671,13 @@ func (h *AdminLookupHandler) ListSpecialties(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
func (h *AdminLookupHandler) CreateSpecialty(c echo.Context) error {
var req CreateUpdateSpecialtyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
specialty := models.ContractorSpecialty{
@@ -753,14 +690,13 @@ func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
}
if err := h.db.Create(&specialty).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create specialty"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create specialty"})
}
// Refresh cache after creating
h.refreshSpecialtiesCache(c.Request.Context())
h.refreshSpecialtiesCache(c.Request().Context())
c.JSON(http.StatusCreated, ContractorSpecialtyResponse{
return c.JSON(http.StatusCreated, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
Description: specialty.Description,
@@ -769,27 +705,23 @@ func (h *AdminLookupHandler) CreateSpecialty(c *gin.Context) {
})
}
func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
func (h *AdminLookupHandler) UpdateSpecialty(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid specialty ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid specialty ID"})
}
var specialty models.ContractorSpecialty
if err := h.db.First(&specialty, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Specialty not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Specialty not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch specialty"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch specialty"})
}
var req CreateUpdateSpecialtyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
specialty.Name = req.Name
@@ -800,14 +732,13 @@ func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
}
if err := h.db.Save(&specialty).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update specialty"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update specialty"})
}
// Refresh cache after updating
h.refreshSpecialtiesCache(c.Request.Context())
h.refreshSpecialtiesCache(c.Request().Context())
c.JSON(http.StatusOK, ContractorSpecialtyResponse{
return c.JSON(http.StatusOK, ContractorSpecialtyResponse{
ID: specialty.ID,
Name: specialty.Name,
Description: specialty.Description,
@@ -816,30 +747,27 @@ func (h *AdminLookupHandler) UpdateSpecialty(c *gin.Context) {
})
}
func (h *AdminLookupHandler) DeleteSpecialty(c *gin.Context) {
func (h *AdminLookupHandler) DeleteSpecialty(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid specialty ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid specialty ID"})
}
// Check if in use via many-to-many relationship
var count int64
h.db.Table("task_contractor_specialties").Where("contractorspecialty_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete specialty that is in use by contractors"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Cannot delete specialty that is in use by contractors"})
}
if err := h.db.Delete(&models.ContractorSpecialty{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete specialty"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete specialty"})
}
// Refresh cache after deleting
h.refreshSpecialtiesCache(c.Request.Context())
h.refreshSpecialtiesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Specialty deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Specialty deleted successfully"})
}
// Ensure dto import is used

View File

@@ -6,7 +6,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -32,11 +32,10 @@ func NewAdminNotificationHandler(db *gorm.DB, emailService *services.EmailServic
}
// List handles GET /api/admin/notifications
func (h *AdminNotificationHandler) List(c *gin.Context) {
func (h *AdminNotificationHandler) List(c echo.Context) error {
var filters dto.NotificationFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var notifications []models.Notification
@@ -79,8 +78,7 @@ func (h *AdminNotificationHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&notifications).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notifications"})
}
// Build response
@@ -89,15 +87,14 @@ func (h *AdminNotificationHandler) List(c *gin.Context) {
responses[i] = h.toNotificationResponse(&notif)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/notifications/:id
func (h *AdminNotificationHandler) Get(c *gin.Context) {
func (h *AdminNotificationHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid notification ID"})
}
var notification models.Notification
@@ -105,65 +102,55 @@ func (h *AdminNotificationHandler) Get(c *gin.Context) {
Preload("User").
First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification"})
}
c.JSON(http.StatusOK, h.toNotificationDetailResponse(&notification))
return c.JSON(http.StatusOK, h.toNotificationDetailResponse(&notification))
}
// Delete handles DELETE /api/admin/notifications/:id
func (h *AdminNotificationHandler) Delete(c *gin.Context) {
func (h *AdminNotificationHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid notification ID"})
}
var notification models.Notification
if err := h.db.First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification"})
}
// Hard delete notifications
if err := h.db.Delete(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification"})
}
c.JSON(http.StatusOK, gin.H{"message": "Notification deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Notification deleted successfully"})
}
// Update handles PUT /api/admin/notifications/:id
func (h *AdminNotificationHandler) Update(c *gin.Context) {
func (h *AdminNotificationHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid notification ID"})
}
var notification models.Notification
if err := h.db.First(&notification, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification"})
}
var req dto.UpdateNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
now := time.Now().UTC()
@@ -183,16 +170,15 @@ func (h *AdminNotificationHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update notification"})
}
h.db.Preload("User").First(&notification, id)
c.JSON(http.StatusOK, h.toNotificationResponse(&notification))
return c.JSON(http.StatusOK, h.toNotificationResponse(&notification))
}
// GetStats handles GET /api/admin/notifications/stats
func (h *AdminNotificationHandler) GetStats(c *gin.Context) {
func (h *AdminNotificationHandler) GetStats(c echo.Context) error {
var total, sent, read, pending int64
h.db.Model(&models.Notification{}).Count(&total)
@@ -200,7 +186,7 @@ func (h *AdminNotificationHandler) GetStats(c *gin.Context) {
h.db.Model(&models.Notification{}).Where("read = ?", true).Count(&read)
h.db.Model(&models.Notification{}).Where("sent = ?", false).Count(&pending)
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"total": total,
"sent": sent,
"read": read,
@@ -245,22 +231,19 @@ func (h *AdminNotificationHandler) toNotificationDetailResponse(notif *models.No
}
// SendTestNotification handles POST /api/admin/notifications/send-test
func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
func (h *AdminNotificationHandler) SendTestNotification(c echo.Context) error {
var req dto.SendTestNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
// Get user's device tokens
@@ -271,8 +254,7 @@ func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
h.db.Where("user_id = ? AND active = ?", req.UserID, true).Find(&androidDevices)
if len(iosDevices) == 0 && len(androidDevices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no registered devices"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "User has no registered devices"})
}
// Create notification record
@@ -287,8 +269,7 @@ func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
}
if err := h.db.Create(&notification).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create notification record"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create notification record"})
}
// Collect tokens
@@ -316,15 +297,13 @@ func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
h.db.Model(&notification).Updates(map[string]interface{}{
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": "Failed to send push notification",
"details": err.Error(),
})
return
}
} else {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Push notification service not configured"})
return
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Push notification service not configured"})
}
// Mark as sent
@@ -333,10 +312,10 @@ func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
"sent_at": now,
})
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Test notification sent successfully",
"notification_id": notification.ID,
"devices": gin.H{
"devices": map[string]interface{}{
"ios": len(iosTokens),
"android": len(androidTokens),
},
@@ -344,33 +323,28 @@ func (h *AdminNotificationHandler) SendTestNotification(c *gin.Context) {
}
// SendTestEmail handles POST /api/admin/emails/send-test
func (h *AdminNotificationHandler) SendTestEmail(c *gin.Context) {
func (h *AdminNotificationHandler) SendTestEmail(c echo.Context) error {
var req dto.SendTestEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no email address"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "User has no email address"})
}
// Send email
if h.emailService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email service not configured"})
return
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Email service not configured"})
}
// Create HTML body with basic styling
@@ -390,61 +364,54 @@ func (h *AdminNotificationHandler) SendTestEmail(c *gin.Context) {
err := h.emailService.SendEmail(user.Email, req.Subject, htmlBody, req.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": "Failed to send email",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Test email sent successfully",
"to": user.Email,
})
}
// SendPostVerificationEmail handles POST /api/admin/emails/send-post-verification
func (h *AdminNotificationHandler) SendPostVerificationEmail(c *gin.Context) {
func (h *AdminNotificationHandler) SendPostVerificationEmail(c echo.Context) error {
var req struct {
UserID uint `json:"user_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "user_id is required"})
}
// Verify user exists
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
if user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no email address"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "User has no email address"})
}
// Send email
if h.emailService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email service not configured"})
return
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Email service not configured"})
}
err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": "Failed to send email",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Post-verification email sent successfully",
"to": user.Email,
})

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -49,11 +49,10 @@ type NotificationPrefResponse struct {
}
// List handles GET /api/admin/notification-prefs
func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
func (h *AdminNotificationPrefsHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var prefs []models.NotificationPreference
@@ -85,8 +84,7 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&prefs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preferences"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification preferences"})
}
// Get user info for each preference
@@ -130,31 +128,28 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) Get(c *gin.Context) {
func (h *AdminNotificationPrefsHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var pref models.NotificationPreference
if err := h.db.First(&pref, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification preference not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification preference"})
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
return c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
@@ -197,27 +192,23 @@ type UpdateNotificationPrefRequest struct {
}
// Update handles PUT /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
func (h *AdminNotificationPrefsHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var pref models.NotificationPreference
if err := h.db.First(&pref, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification preference not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification preference"})
}
var req UpdateNotificationPrefRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Apply updates
@@ -261,14 +252,13 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&pref).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update notification preference"})
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
return c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
@@ -291,48 +281,42 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
}
// Delete handles DELETE /api/admin/notification-prefs/:id
func (h *AdminNotificationPrefsHandler) Delete(c *gin.Context) {
func (h *AdminNotificationPrefsHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.NotificationPreference{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification preference"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification preference"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification preference not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Notification preference deleted"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Notification preference deleted"})
}
// GetByUser handles GET /api/admin/notification-prefs/user/:user_id
func (h *AdminNotificationPrefsHandler) GetByUser(c *gin.Context) {
func (h *AdminNotificationPrefsHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var pref models.NotificationPreference
if err := h.db.Where("user_id = ?", userID).First(&pref).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification preference not found for this user"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Notification preference not found for this user"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notification preference"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch notification preference"})
}
var user models.User
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
return c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
@@ -62,12 +62,21 @@ type OnboardingStatsResponse struct {
// List returns paginated list of onboarding emails
// GET /api/admin/onboarding-emails
func (h *AdminOnboardingHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
emailType := c.Query("email_type")
userID, _ := strconv.Atoi(c.Query("user_id"))
opened := c.Query("opened")
func (h *AdminOnboardingHandler) List(c echo.Context) error {
pageStr := c.QueryParam("page")
if pageStr == "" {
pageStr = "1"
}
page, _ := strconv.Atoi(pageStr)
pageSizeStr := c.QueryParam("page_size")
if pageSizeStr == "" {
pageSizeStr = "20"
}
pageSize, _ := strconv.Atoi(pageSizeStr)
emailType := c.QueryParam("email_type")
userID, _ := strconv.Atoi(c.QueryParam("user_id"))
opened := c.QueryParam("opened")
if page < 1 {
page = 1
@@ -96,15 +105,13 @@ func (h *AdminOnboardingHandler) List(c *gin.Context) {
// Count total
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count emails"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to count emails"})
}
// Get paginated results
var emails []models.OnboardingEmail
if err := query.Order("sent_at DESC").Offset(offset).Limit(pageSize).Find(&emails).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch emails"})
}
// Transform to response
@@ -118,7 +125,7 @@ func (h *AdminOnboardingHandler) List(c *gin.Context) {
totalPages++
}
c.JSON(http.StatusOK, OnboardingEmailListResponse{
return c.JSON(http.StatusOK, OnboardingEmailListResponse{
Data: data,
Total: total,
Page: page,
@@ -129,7 +136,7 @@ func (h *AdminOnboardingHandler) List(c *gin.Context) {
// GetStats returns onboarding email statistics
// GET /api/admin/onboarding-emails/stats
func (h *AdminOnboardingHandler) GetStats(c *gin.Context) {
func (h *AdminOnboardingHandler) GetStats(c echo.Context) error {
var stats OnboardingStatsResponse
// No residence email stats
@@ -162,22 +169,20 @@ func (h *AdminOnboardingHandler) GetStats(c *gin.Context) {
stats.OverallRate = float64(stats.TotalOpened) / float64(stats.TotalSent) * 100
}
c.JSON(http.StatusOK, stats)
return c.JSON(http.StatusOK, stats)
}
// GetByUser returns onboarding emails for a specific user
// GET /api/admin/onboarding-emails/user/:user_id
func (h *AdminOnboardingHandler) GetByUser(c *gin.Context) {
func (h *AdminOnboardingHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var emails []models.OnboardingEmail
if err := h.db.Where("user_id = ?", userID).Order("sent_at DESC").Find(&emails).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch emails"})
}
// Transform to response
@@ -186,7 +191,7 @@ func (h *AdminOnboardingHandler) GetByUser(c *gin.Context) {
data[i] = transformOnboardingEmail(email)
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"data": data,
"user_id": userID,
"count": len(data),
@@ -195,71 +200,62 @@ func (h *AdminOnboardingHandler) GetByUser(c *gin.Context) {
// Get returns a single onboarding email by ID
// GET /api/admin/onboarding-emails/:id
func (h *AdminOnboardingHandler) Get(c *gin.Context) {
func (h *AdminOnboardingHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var email models.OnboardingEmail
if err := h.db.Preload("User").First(&email, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Email not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch email"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch email"})
}
c.JSON(http.StatusOK, transformOnboardingEmail(email))
return c.JSON(http.StatusOK, transformOnboardingEmail(email))
}
// Delete removes an onboarding email record
// DELETE /api/admin/onboarding-emails/:id
func (h *AdminOnboardingHandler) Delete(c *gin.Context) {
func (h *AdminOnboardingHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.OnboardingEmail{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete email"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete email"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Email not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Email record deleted"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Email record deleted"})
}
// BulkDelete removes multiple onboarding email records
// DELETE /api/admin/onboarding-emails/bulk
func (h *AdminOnboardingHandler) BulkDelete(c *gin.Context) {
func (h *AdminOnboardingHandler) BulkDelete(c echo.Context) error {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request"})
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No IDs provided"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "No IDs provided"})
}
result := h.db.Delete(&models.OnboardingEmail{}, req.IDs)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete emails"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete emails"})
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Emails deleted",
"count": result.RowsAffected,
})
@@ -273,16 +269,14 @@ type SendOnboardingEmailRequest struct {
// Send sends an onboarding email to a specific user
// POST /api/admin/onboarding-emails/send
func (h *AdminOnboardingHandler) Send(c *gin.Context) {
func (h *AdminOnboardingHandler) Send(c echo.Context) error {
if h.onboardingService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Onboarding email service not configured"})
return
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Onboarding email service not configured"})
}
var req SendOnboardingEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: user_id and email_type are required"})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request: user_id and email_type are required"})
}
// Validate email type
@@ -293,28 +287,24 @@ func (h *AdminOnboardingHandler) Send(c *gin.Context) {
case "no_tasks":
emailType = models.OnboardingEmailNoTasks
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email_type. Must be 'no_residence' or 'no_tasks'"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid email_type. Must be 'no_residence' or 'no_tasks'"})
}
// Get user email for response
var user models.User
if err := h.db.First(&user, req.UserID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
// Send the email
if err := h.onboardingService.SendOnboardingEmailToUser(req.UserID, emailType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Onboarding email sent successfully",
"user_id": req.UserID,
"email": user.Email,

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -36,11 +36,10 @@ type PasswordResetCodeResponse struct {
}
// List handles GET /api/admin/password-reset-codes
func (h *AdminPasswordResetCodeHandler) List(c *gin.Context) {
func (h *AdminPasswordResetCodeHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var codes []models.PasswordResetCode
@@ -72,8 +71,7 @@ func (h *AdminPasswordResetCodeHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&codes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset codes"})
}
// Build response
@@ -93,25 +91,22 @@ func (h *AdminPasswordResetCodeHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/password-reset-codes/:id
func (h *AdminPasswordResetCodeHandler) Get(c *gin.Context) {
func (h *AdminPasswordResetCodeHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.PasswordResetCode
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset code"})
}
response := PasswordResetCodeResponse{
@@ -127,44 +122,39 @@ func (h *AdminPasswordResetCodeHandler) Get(c *gin.Context) {
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/password-reset-codes/:id
func (h *AdminPasswordResetCodeHandler) Delete(c *gin.Context) {
func (h *AdminPasswordResetCodeHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.PasswordResetCode{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset code"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Password reset code deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset code deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/password-reset-codes/bulk
func (h *AdminPasswordResetCodeHandler) BulkDelete(c *gin.Context) {
func (h *AdminPasswordResetCodeHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.PasswordResetCode{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes"})
}
c.JSON(http.StatusOK, gin.H{"message": "Password reset codes deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset codes deleted successfully", "count": result.RowsAffected})
}

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -38,11 +38,10 @@ type PromotionResponse struct {
}
// List handles GET /api/admin/promotions
func (h *AdminPromotionHandler) List(c *gin.Context) {
func (h *AdminPromotionHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var promotions []models.Promotion
@@ -65,8 +64,7 @@ func (h *AdminPromotionHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&promotions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotions"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch promotions"})
}
responses := make([]PromotionResponse, len(promotions))
@@ -86,25 +84,22 @@ func (h *AdminPromotionHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/promotions/:id
func (h *AdminPromotionHandler) Get(c *gin.Context) {
func (h *AdminPromotionHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var promotion models.Promotion
if err := h.db.First(&promotion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Promotion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch promotion"})
}
response := PromotionResponse{
@@ -121,11 +116,11 @@ func (h *AdminPromotionHandler) Get(c *gin.Context) {
UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Create handles POST /api/admin/promotions
func (h *AdminPromotionHandler) Create(c *gin.Context) {
func (h *AdminPromotionHandler) Create(c echo.Context) error {
var req struct {
PromotionID string `json:"promotion_id" binding:"required"`
Title string `json:"title" binding:"required"`
@@ -137,17 +132,15 @@ func (h *AdminPromotionHandler) Create(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
startDate, err := time.Parse("2006-01-02T15:04:05Z", req.StartDate)
if err != nil {
startDate, err = time.Parse("2006-01-02", req.StartDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid start_date format"})
}
}
@@ -155,8 +148,7 @@ func (h *AdminPromotionHandler) Create(c *gin.Context) {
if err != nil {
endDate, err = time.Parse("2006-01-02", req.EndDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid end_date format"})
}
}
@@ -181,11 +173,10 @@ func (h *AdminPromotionHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&promotion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create promotion"})
}
c.JSON(http.StatusCreated, PromotionResponse{
return c.JSON(http.StatusCreated, PromotionResponse{
ID: promotion.ID,
PromotionID: promotion.PromotionID,
Title: promotion.Title,
@@ -201,21 +192,18 @@ func (h *AdminPromotionHandler) Create(c *gin.Context) {
}
// Update handles PUT /api/admin/promotions/:id
func (h *AdminPromotionHandler) Update(c *gin.Context) {
func (h *AdminPromotionHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var promotion models.Promotion
if err := h.db.First(&promotion, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Promotion not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch promotion"})
}
var req struct {
@@ -229,9 +217,8 @@ func (h *AdminPromotionHandler) Update(c *gin.Context) {
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.PromotionID != nil {
@@ -251,8 +238,7 @@ func (h *AdminPromotionHandler) Update(c *gin.Context) {
if err != nil {
startDate, err = time.Parse("2006-01-02", *req.StartDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid start_date format"})
}
}
promotion.StartDate = startDate
@@ -262,8 +248,7 @@ func (h *AdminPromotionHandler) Update(c *gin.Context) {
if err != nil {
endDate, err = time.Parse("2006-01-02", *req.EndDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid end_date format"})
}
}
promotion.EndDate = endDate
@@ -280,11 +265,10 @@ func (h *AdminPromotionHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&promotion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update promotion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update promotion"})
}
c.JSON(http.StatusOK, PromotionResponse{
return c.JSON(http.StatusOK, PromotionResponse{
ID: promotion.ID,
PromotionID: promotion.PromotionID,
Title: promotion.Title,
@@ -300,23 +284,20 @@ func (h *AdminPromotionHandler) Update(c *gin.Context) {
}
// Delete handles DELETE /api/admin/promotions/:id
func (h *AdminPromotionHandler) Delete(c *gin.Context) {
func (h *AdminPromotionHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.Promotion{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete promotion"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete promotion"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Promotion not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Promotion deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Promotion deleted successfully"})
}

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -24,11 +24,10 @@ func NewAdminResidenceHandler(db *gorm.DB) *AdminResidenceHandler {
}
// List handles GET /api/admin/residences
func (h *AdminResidenceHandler) List(c *gin.Context) {
func (h *AdminResidenceHandler) List(c echo.Context) error {
var filters dto.ResidenceFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var residences []models.Residence
@@ -70,8 +69,7 @@ func (h *AdminResidenceHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&residences).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residences"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residences"})
}
// Build response
@@ -80,25 +78,22 @@ func (h *AdminResidenceHandler) List(c *gin.Context) {
responses[i] = h.toResidenceResponse(&res)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/residences/:id
func (h *AdminResidenceHandler) Get(c *gin.Context) {
func (h *AdminResidenceHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid residence ID"})
}
var residence models.Residence
if err := h.db.Preload("Owner").Preload("PropertyType").Preload("Users").First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Residence not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residence"})
}
response := dto.ResidenceDetailResponse{
@@ -128,39 +123,34 @@ func (h *AdminResidenceHandler) Get(c *gin.Context) {
response.TaskCount = int(taskCount)
response.DocumentCount = int(documentCount)
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/residences/:id
func (h *AdminResidenceHandler) Update(c *gin.Context) {
func (h *AdminResidenceHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid residence ID"})
}
var residence models.Residence
if err := h.db.First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Residence not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residence"})
}
var req dto.UpdateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.OwnerID != nil {
// Verify owner exists
var owner models.User
if err := h.db.First(&owner, *req.OwnerID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Owner not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Owner not found"})
}
residence.OwnerID = *req.OwnerID
}
@@ -225,27 +215,24 @@ func (h *AdminResidenceHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update residence"})
}
h.db.Preload("Owner").Preload("PropertyType").First(&residence, id)
c.JSON(http.StatusOK, h.toResidenceResponse(&residence))
return c.JSON(http.StatusOK, h.toResidenceResponse(&residence))
}
// Create handles POST /api/admin/residences
func (h *AdminResidenceHandler) Create(c *gin.Context) {
func (h *AdminResidenceHandler) Create(c echo.Context) error {
var req dto.CreateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify owner exists
var owner models.User
if err := h.db.First(&owner, req.OwnerID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Owner not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Owner not found"})
}
residence := models.Residence{
@@ -278,57 +265,50 @@ func (h *AdminResidenceHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create residence"})
}
h.db.Preload("Owner").Preload("PropertyType").First(&residence, residence.ID)
c.JSON(http.StatusCreated, h.toResidenceResponse(&residence))
return c.JSON(http.StatusCreated, h.toResidenceResponse(&residence))
}
// Delete handles DELETE /api/admin/residences/:id
func (h *AdminResidenceHandler) Delete(c *gin.Context) {
func (h *AdminResidenceHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid residence ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid residence ID"})
}
var residence models.Residence
if err := h.db.First(&residence, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Residence not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch residence"})
}
// Soft delete
residence.IsActive = false
if err := h.db.Save(&residence).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence"})
}
c.JSON(http.StatusOK, gin.H{"message": "Residence deactivated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Residence deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/residences/bulk
func (h *AdminResidenceHandler) BulkDelete(c *gin.Context) {
func (h *AdminResidenceHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Soft delete - deactivate all
if err := h.db.Model(&models.Residence{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residences"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residences"})
}
c.JSON(http.StatusOK, gin.H{"message": "Residences deactivated successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Residences deactivated successfully", "count": len(req.IDs)})
}
func (h *AdminResidenceHandler) toResidenceResponse(res *models.Residence) dto.ResidenceResponse {

View File

@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -33,7 +33,7 @@ type SettingsResponse struct {
}
// GetSettings handles GET /api/admin/settings
func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
func (h *AdminSettingsHandler) GetSettings(c echo.Context) error {
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -41,12 +41,11 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false, EnableMonitoring: true}
h.db.Create(&settings)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
c.JSON(http.StatusOK, SettingsResponse{
return c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
})
@@ -59,11 +58,10 @@ type UpdateSettingsRequest struct {
}
// UpdateSettings handles PUT /api/admin/settings
func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
func (h *AdminSettingsHandler) UpdateSettings(c echo.Context) error {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var settings models.SubscriptionSettings
@@ -71,8 +69,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{ID: 1, EnableMonitoring: true}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch settings"})
}
}
@@ -85,11 +82,10 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
}
if err := h.db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update settings"})
}
c.JSON(http.StatusOK, SettingsResponse{
return c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
})
@@ -97,31 +93,29 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
// SeedLookups handles POST /api/admin/settings/seed-lookups
// Seeds both lookup tables AND task templates, then caches all lookups in Redis
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
func (h *AdminSettingsHandler) SeedLookups(c echo.Context) error {
// First seed lookup tables
if err := h.runSeedFile("001_lookups.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed lookups: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed lookups: " + err.Error()})
}
// Then seed task templates
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed task templates: " + err.Error()})
}
// Cache all lookups in Redis
cached, cacheErr := h.cacheAllLookups(c.Request.Context())
cached, cacheErr := h.cacheAllLookups(c.Request().Context())
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache lookups in Redis, but seed was successful")
}
response := gin.H{
response := map[string]interface{}{
"message": "Lookup data and task templates seeded successfully",
"redis_cached": cached,
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// cacheAllLookups fetches all lookup data from the database and caches it in Redis
@@ -326,23 +320,21 @@ func parseTags(tags string) []string {
}
// SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c *gin.Context) {
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
if err := h.runSeedFile("002_test_data.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed test data: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed test data: " + err.Error()})
}
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Test data seeded successfully"})
}
// SeedTaskTemplates handles POST /api/admin/settings/seed-task-templates
func (h *AdminSettingsHandler) SeedTaskTemplates(c *gin.Context) {
func (h *AdminSettingsHandler) SeedTaskTemplates(c echo.Context) error {
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to seed task templates: " + err.Error()})
}
c.JSON(http.StatusOK, gin.H{"message": "Task templates seeded successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Task templates seeded successfully"})
}
// runSeedFile executes a seed SQL file
@@ -473,14 +465,13 @@ type ClearStuckJobsResponse struct {
// ClearStuckJobs handles POST /api/admin/settings/clear-stuck-jobs
// This clears stuck/failed asynq worker jobs from Redis
func (h *AdminSettingsHandler) ClearStuckJobs(c *gin.Context) {
func (h *AdminSettingsHandler) ClearStuckJobs(c echo.Context) error {
cache := services.GetCache()
if cache == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Redis cache not available"})
return
return c.JSON(http.StatusServiceUnavailable, map[string]interface{}{"error": "Redis cache not available"})
}
ctx := c.Request.Context()
ctx := c.Request().Context()
client := cache.Client()
var deletedKeys []string
@@ -526,7 +517,7 @@ func (h *AdminSettingsHandler) ClearStuckJobs(c *gin.Context) {
log.Info().Int("count", len(deletedKeys)).Strs("keys", deletedKeys).Msg("Cleared stuck Redis jobs")
c.JSON(http.StatusOK, ClearStuckJobsResponse{
return c.JSON(http.StatusOK, ClearStuckJobsResponse{
Message: "Stuck jobs cleared successfully",
KeysDeleted: len(deletedKeys),
DeletedKeys: deletedKeys,
@@ -535,12 +526,11 @@ func (h *AdminSettingsHandler) ClearStuckJobs(c *gin.Context) {
// ClearAllData handles POST /api/admin/settings/clear-all-data
// This clears all data except super admin accounts and lookup tables
func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
func (h *AdminSettingsHandler) ClearAllData(c echo.Context) error {
// Start a transaction
tx := h.db.Begin()
if tx.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to start transaction"})
}
defer func() {
@@ -555,8 +545,7 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
Where("is_superuser = ?", true).
Pluck("id", &preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get superuser IDs"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to get superuser IDs"})
}
// Count users that will be deleted
@@ -565,8 +554,7 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
Where("is_superuser = ?", false).
Count(&usersToDelete).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count users"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to count users"})
}
// Delete in order to respect foreign key constraints
@@ -575,110 +563,94 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
// 1. Delete task completion images
if err := tx.Exec("DELETE FROM task_taskcompletionimage").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task completion images: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task completion images: " + err.Error()})
}
// 2. Delete task completions
if err := tx.Exec("DELETE FROM task_taskcompletion").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task completions: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task completions: " + err.Error()})
}
// 3. Delete notifications (must be before tasks since notifications have task_id FK)
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM notifications_notification WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notifications: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notifications: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM notifications_notification").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notifications: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notifications: " + err.Error()})
}
}
// 4. Delete document images
if err := tx.Exec("DELETE FROM task_documentimage").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document images: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete document images: " + err.Error()})
}
// 5. Delete documents
if err := tx.Exec("DELETE FROM task_document").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete documents: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete documents: " + err.Error()})
}
// 6. Delete tasks (must be before contractors since tasks reference contractors)
if err := tx.Exec("DELETE FROM task_task").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete tasks: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete tasks: " + err.Error()})
}
// 7. Delete contractor specialties (many-to-many)
if err := tx.Exec("DELETE FROM task_contractor_specialties").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractor specialties: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractor specialties: " + err.Error()})
}
// 8. Delete contractors
if err := tx.Exec("DELETE FROM task_contractor").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contractors: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete contractors: " + err.Error()})
}
// 9. Delete residence_users (many-to-many for shared residences)
if err := tx.Exec("DELETE FROM residence_residence_users").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence users: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence users: " + err.Error()})
}
// 10. Delete residence share codes (must be before residences since share codes have residence_id FK)
if err := tx.Exec("DELETE FROM residence_residencesharecode").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residence share codes: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residence share codes: " + err.Error()})
}
// 11. Delete residences
if err := tx.Exec("DELETE FROM residence_residence").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete residences: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete residences: " + err.Error()})
}
// 12. Delete push devices for non-superusers (both APNS and GCM)
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM push_notifications_apnsdevice WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete APNS devices: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete APNS devices: " + err.Error()})
}
if err := tx.Exec("DELETE FROM push_notifications_gcmdevice WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete GCM devices: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete GCM devices: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM push_notifications_apnsdevice").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete APNS devices: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete APNS devices: " + err.Error()})
}
if err := tx.Exec("DELETE FROM push_notifications_gcmdevice").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete GCM devices: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete GCM devices: " + err.Error()})
}
}
@@ -686,14 +658,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM notifications_notificationpreference WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification preferences: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification preferences: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM notifications_notificationpreference").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete notification preferences: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete notification preferences: " + err.Error()})
}
}
@@ -701,14 +671,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM subscription_usersubscription WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriptions: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete subscriptions: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM subscription_usersubscription").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriptions: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete subscriptions: " + err.Error()})
}
}
@@ -716,14 +684,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_passwordresetcode WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset codes: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM user_passwordresetcode").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset codes: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes: " + err.Error()})
}
}
@@ -731,14 +697,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_confirmationcode WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation codes: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM user_confirmationcode").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation codes: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes: " + err.Error()})
}
}
@@ -746,14 +710,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_authtoken WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete auth tokens: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete auth tokens: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM user_authtoken").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete auth tokens: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete auth tokens: " + err.Error()})
}
}
@@ -761,14 +723,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_applesocialauth WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM user_applesocialauth").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth: " + err.Error()})
}
}
@@ -776,14 +736,12 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
if len(preservedUserIDs) > 0 {
if err := tx.Exec("DELETE FROM user_userprofile WHERE user_id NOT IN (?)", preservedUserIDs).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profiles: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profiles: " + err.Error()})
}
} else {
if err := tx.Exec("DELETE FROM user_userprofile").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profiles: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profiles: " + err.Error()})
}
}
@@ -791,17 +749,15 @@ func (h *AdminSettingsHandler) ClearAllData(c *gin.Context) {
// Always filter by is_superuser to be safe, regardless of preservedUserIDs
if err := tx.Exec("DELETE FROM auth_user WHERE is_superuser = false").Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete users: " + err.Error()})
}
// Commit the transaction
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction: " + err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to commit transaction: " + err.Error()})
}
c.JSON(http.StatusOK, ClearAllDataResponse{
return c.JSON(http.StatusOK, ClearAllDataResponse{
Message: "All data cleared successfully (superadmin accounts preserved)",
UsersDeleted: usersToDelete,
PreservedUsers: int64(len(preservedUserIDs)),

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -35,11 +35,10 @@ type ShareCodeResponse struct {
}
// List handles GET /api/admin/share-codes
func (h *AdminShareCodeHandler) List(c *gin.Context) {
func (h *AdminShareCodeHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var codes []models.ResidenceShareCode
@@ -74,8 +73,7 @@ func (h *AdminShareCodeHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&codes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch share codes"})
}
// Build response
@@ -100,25 +98,22 @@ func (h *AdminShareCodeHandler) List(c *gin.Context) {
}
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/share-codes/:id
func (h *AdminShareCodeHandler) Get(c *gin.Context) {
func (h *AdminShareCodeHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.ResidenceShareCode
if err := h.db.Preload("Residence").Preload("CreatedBy").First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Share code not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch share code"})
}
var expiresAt *string
@@ -139,40 +134,35 @@ func (h *AdminShareCodeHandler) Get(c *gin.Context) {
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/share-codes/:id
func (h *AdminShareCodeHandler) Update(c *gin.Context) {
func (h *AdminShareCodeHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.ResidenceShareCode
if err := h.db.First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Share code not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch share code"})
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
code.IsActive = req.IsActive
if err := h.db.Save(&code).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update share code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update share code"})
}
// Reload with relations
@@ -196,44 +186,39 @@ func (h *AdminShareCodeHandler) Update(c *gin.Context) {
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/share-codes/:id
func (h *AdminShareCodeHandler) Delete(c *gin.Context) {
func (h *AdminShareCodeHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.ResidenceShareCode{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share code"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete share code"})
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Share code not found"})
}
c.JSON(http.StatusOK, gin.H{"message": "Share code deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Share code deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/share-codes/bulk
func (h *AdminShareCodeHandler) BulkDelete(c *gin.Context) {
func (h *AdminShareCodeHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ResidenceShareCode{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share codes"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete share codes"})
}
c.JSON(http.StatusOK, gin.H{"message": "Share codes deleted successfully", "count": result.RowsAffected})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Share codes deleted successfully", "count": result.RowsAffected})
}

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -22,11 +22,10 @@ func NewAdminSubscriptionHandler(db *gorm.DB) *AdminSubscriptionHandler {
}
// List handles GET /api/admin/subscriptions
func (h *AdminSubscriptionHandler) List(c *gin.Context) {
func (h *AdminSubscriptionHandler) List(c echo.Context) error {
var filters dto.SubscriptionFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var subscriptions []models.UserSubscription
@@ -77,8 +76,7 @@ func (h *AdminSubscriptionHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&subscriptions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriptions"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch subscriptions"})
}
// Build response
@@ -87,15 +85,14 @@ func (h *AdminSubscriptionHandler) List(c *gin.Context) {
responses[i] = h.toSubscriptionResponse(&sub)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/subscriptions/:id
func (h *AdminSubscriptionHandler) Get(c *gin.Context) {
func (h *AdminSubscriptionHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscription ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid subscription ID"})
}
var subscription models.UserSubscription
@@ -103,38 +100,32 @@ func (h *AdminSubscriptionHandler) Get(c *gin.Context) {
Preload("User").
First(&subscription, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Subscription not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch subscription"})
}
c.JSON(http.StatusOK, h.toSubscriptionDetailResponse(&subscription))
return c.JSON(http.StatusOK, h.toSubscriptionDetailResponse(&subscription))
}
// Update handles PUT /api/admin/subscriptions/:id
func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
func (h *AdminSubscriptionHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscription ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid subscription ID"})
}
var subscription models.UserSubscription
if err := h.db.First(&subscription, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Subscription not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch subscription"})
}
var req dto.UpdateSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.Tier != nil {
@@ -148,20 +139,18 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
}
if err := h.db.Save(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update subscription"})
}
h.db.Preload("User").First(&subscription, id)
c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
return c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
}
// GetByUser handles GET /api/admin/subscriptions/user/:user_id
func (h *AdminSubscriptionHandler) GetByUser(c *gin.Context) {
func (h *AdminSubscriptionHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var subscription models.UserSubscription
@@ -180,22 +169,20 @@ func (h *AdminSubscriptionHandler) GetByUser(c *gin.Context) {
IsFree: false,
}
if err := h.db.Create(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscription"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create subscription"})
}
// Reload with user
h.db.Preload("User").First(&subscription, subscription.ID)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch subscription"})
}
}
c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
return c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
}
// GetStats handles GET /api/admin/subscriptions/stats
func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
func (h *AdminSubscriptionHandler) GetStats(c echo.Context) error {
var total, free, premium, pro int64
h.db.Model(&models.UserSubscription{}).Count(&total)
@@ -203,7 +190,7 @@ func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "premium").Count(&premium)
h.db.Model(&models.UserSubscription{}).Where("tier = ?", "pro").Count(&pro)
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"total": total,
"free": free,
"premium": premium,

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -24,11 +24,10 @@ func NewAdminTaskHandler(db *gorm.DB) *AdminTaskHandler {
}
// List handles GET /api/admin/tasks
func (h *AdminTaskHandler) List(c *gin.Context) {
func (h *AdminTaskHandler) List(c echo.Context) error {
var filters dto.TaskFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var tasks []models.Task
@@ -80,8 +79,7 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&tasks).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tasks"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch tasks"})
}
// Build response
@@ -90,15 +88,14 @@ func (h *AdminTaskHandler) List(c *gin.Context) {
responses[i] = h.toTaskResponse(&task)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/tasks/:id
func (h *AdminTaskHandler) Get(c *gin.Context) {
func (h *AdminTaskHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid task ID"})
}
var task models.Task
@@ -112,11 +109,9 @@ func (h *AdminTaskHandler) Get(c *gin.Context) {
Preload("Completions").
First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Task not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch task"})
}
response := dto.TaskDetailResponse{
@@ -133,55 +128,48 @@ func (h *AdminTaskHandler) Get(c *gin.Context) {
response.CompletionCount = len(task.Completions)
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Update handles PUT /api/admin/tasks/:id
func (h *AdminTaskHandler) Update(c *gin.Context) {
func (h *AdminTaskHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid task ID"})
}
var task models.Task
if err := h.db.First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Task not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch task"})
}
var req dto.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence if changing
if req.ResidenceID != nil {
var residence models.Residence
if err := h.db.First(&residence, *req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
}
// Verify created_by if changing
if req.CreatedByID != nil {
var user models.User
if err := h.db.First(&user, *req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Created by user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Created by user not found"})
}
}
// Verify assigned_to if changing
if req.AssignedToID != nil {
var user models.User
if err := h.db.First(&user, *req.AssignedToID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Assigned to user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Assigned to user not found"})
}
}
@@ -247,35 +235,31 @@ func (h *AdminTaskHandler) Update(c *gin.Context) {
// Use Updates with map to only update specified fields
if err := h.db.Model(&task).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update task"})
}
// Reload with preloads for response
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, id)
c.JSON(http.StatusOK, h.toTaskResponse(&task))
return c.JSON(http.StatusOK, h.toTaskResponse(&task))
}
// Create handles POST /api/admin/tasks
func (h *AdminTaskHandler) Create(c *gin.Context) {
func (h *AdminTaskHandler) Create(c echo.Context) error {
var req dto.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Verify residence exists
var residence models.Residence
if err := h.db.First(&residence, req.ResidenceID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Residence not found"})
}
// Verify created_by user exists
var creator models.User
if err := h.db.First(&creator, req.CreatedByID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Creator user not found"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Creator user not found"})
}
task := models.Task{
@@ -305,49 +289,43 @@ func (h *AdminTaskHandler) Create(c *gin.Context) {
}
if err := h.db.Create(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create task"})
}
h.db.Preload("Residence").Preload("CreatedBy").Preload("Category").Preload("Priority").First(&task, task.ID)
c.JSON(http.StatusCreated, h.toTaskResponse(&task))
return c.JSON(http.StatusCreated, h.toTaskResponse(&task))
}
// Delete handles DELETE /api/admin/tasks/:id
func (h *AdminTaskHandler) Delete(c *gin.Context) {
func (h *AdminTaskHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid task ID"})
}
var task models.Task
if err := h.db.First(&task, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Task not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch task"})
}
// Soft delete - archive and cancel
task.IsArchived = true
task.IsCancelled = true
if err := h.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(&task).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete task"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete task"})
}
c.JSON(http.StatusOK, gin.H{"message": "Task archived successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Task archived successfully"})
}
// BulkDelete handles DELETE /api/admin/tasks/bulk
func (h *AdminTaskHandler) BulkDelete(c *gin.Context) {
func (h *AdminTaskHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Soft delete - archive and cancel all
@@ -355,11 +333,10 @@ func (h *AdminTaskHandler) BulkDelete(c *gin.Context) {
"is_archived": true,
"is_cancelled": true,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete tasks"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete tasks"})
}
c.JSON(http.StatusOK, gin.H{"message": "Tasks archived successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Tasks archived successfully", "count": len(req.IDs)})
}
func (h *AdminTaskHandler) toTaskResponse(task *models.Task) dto.TaskResponse {

View File

@@ -6,7 +6,7 @@ import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -28,7 +28,6 @@ func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
func (h *AdminTaskTemplateHandler) refreshTaskTemplatesCache(ctx context.Context) {
cache := services.GetCache()
if cache == nil {
return
}
var templates []models.TaskTemplate
@@ -37,19 +36,16 @@ func (h *AdminTaskTemplateHandler) refreshTaskTemplatesCache(ctx context.Context
Order("display_order ASC, title ASC").
Find(&templates).Error; err != nil {
log.Warn().Err(err).Msg("Failed to fetch task templates for cache refresh")
return
}
if err := cache.CacheTaskTemplates(ctx, templates); err != nil {
log.Warn().Err(err).Msg("Failed to cache task templates")
return
}
log.Debug().Int("count", len(templates)).Msg("Refreshed task templates cache")
// Invalidate unified seeded data cache
if err := cache.InvalidateSeededData(ctx); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate seeded data cache")
return
}
log.Debug().Msg("Invalidated seeded data cache")
}
@@ -84,35 +80,34 @@ type CreateUpdateTaskTemplateRequest struct {
}
// ListTemplates handles GET /admin/api/task-templates/
func (h *AdminTaskTemplateHandler) ListTemplates(c *gin.Context) {
func (h *AdminTaskTemplateHandler) ListTemplates(c echo.Context) error {
var templates []models.TaskTemplate
query := h.db.Preload("Category").Preload("Frequency").Order("display_order ASC, title ASC")
// Optional filter by active status
if activeParam := c.Query("is_active"); activeParam != "" {
if activeParam := c.QueryParam("is_active"); activeParam != "" {
isActive := activeParam == "true"
query = query.Where("is_active = ?", isActive)
}
// Optional filter by category
if categoryID := c.Query("category_id"); categoryID != "" {
if categoryID := c.QueryParam("category_id"); categoryID != "" {
query = query.Where("category_id = ?", categoryID)
}
// Optional filter by frequency
if frequencyID := c.Query("frequency_id"); frequencyID != "" {
if frequencyID := c.QueryParam("frequency_id"); frequencyID != "" {
query = query.Where("frequency_id = ?", frequencyID)
}
// Optional search
if search := c.Query("search"); search != "" {
if search := c.QueryParam("search"); search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
query = query.Where("LOWER(title) LIKE ? OR LOWER(tags) LIKE ?", searchTerm, searchTerm)
}
if err := query.Find(&templates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch templates"})
}
responses := make([]TaskTemplateResponse, len(templates))
@@ -120,36 +115,32 @@ func (h *AdminTaskTemplateHandler) ListTemplates(c *gin.Context) {
responses[i] = h.toResponse(&t)
}
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
}
// GetTemplate handles GET /admin/api/task-templates/:id/
func (h *AdminTaskTemplateHandler) GetTemplate(c *gin.Context) {
func (h *AdminTaskTemplateHandler) GetTemplate(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid template ID"})
}
var template models.TaskTemplate
if err := h.db.Preload("Category").Preload("Frequency").First(&template, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Template not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch template"})
}
c.JSON(http.StatusOK, h.toResponse(&template))
return c.JSON(http.StatusOK, h.toResponse(&template))
}
// CreateTemplate handles POST /admin/api/task-templates/
func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
func (h *AdminTaskTemplateHandler) CreateTemplate(c echo.Context) error {
var req CreateUpdateTaskTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
template := models.TaskTemplate{
@@ -171,41 +162,36 @@ func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
}
if err := h.db.Create(&template).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create template"})
}
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after creating
h.refreshTaskTemplatesCache(c.Request.Context())
h.refreshTaskTemplatesCache(c.Request().Context())
c.JSON(http.StatusCreated, h.toResponse(&template))
return c.JSON(http.StatusCreated, h.toResponse(&template))
}
// UpdateTemplate handles PUT /admin/api/task-templates/:id/
func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
func (h *AdminTaskTemplateHandler) UpdateTemplate(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid template ID"})
}
var template models.TaskTemplate
if err := h.db.First(&template, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Template not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch template"})
}
var req CreateUpdateTaskTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
template.Title = req.Title
@@ -224,79 +210,71 @@ func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
}
if err := h.db.Save(&template).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update template"})
}
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after updating
h.refreshTaskTemplatesCache(c.Request.Context())
h.refreshTaskTemplatesCache(c.Request().Context())
c.JSON(http.StatusOK, h.toResponse(&template))
return c.JSON(http.StatusOK, h.toResponse(&template))
}
// DeleteTemplate handles DELETE /admin/api/task-templates/:id/
func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
func (h *AdminTaskTemplateHandler) DeleteTemplate(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid template ID"})
}
if err := h.db.Delete(&models.TaskTemplate{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete template"})
}
// Refresh cache after deleting
h.refreshTaskTemplatesCache(c.Request.Context())
h.refreshTaskTemplatesCache(c.Request().Context())
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Template deleted successfully"})
}
// ToggleActive handles POST /admin/api/task-templates/:id/toggle-active/
func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
func (h *AdminTaskTemplateHandler) ToggleActive(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid template ID"})
}
var template models.TaskTemplate
if err := h.db.First(&template, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Template not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch template"})
}
template.IsActive = !template.IsActive
if err := h.db.Save(&template).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update template"})
}
// Reload with preloads
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
// Refresh cache after toggling active status
h.refreshTaskTemplatesCache(c.Request.Context())
h.refreshTaskTemplatesCache(c.Request().Context())
c.JSON(http.StatusOK, h.toResponse(&template))
return c.JSON(http.StatusOK, h.toResponse(&template))
}
// BulkCreate handles POST /admin/api/task-templates/bulk/
func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
func (h *AdminTaskTemplateHandler) BulkCreate(c echo.Context) error {
var req struct {
Templates []CreateUpdateTaskTemplateRequest `json:"templates" binding:"required,dive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
templates := make([]models.TaskTemplate, len(req.Templates))
@@ -320,14 +298,13 @@ func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
}
if err := h.db.Create(&templates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create templates"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create templates"})
}
// Refresh cache after bulk creating
h.refreshTaskTemplatesCache(c.Request.Context())
h.refreshTaskTemplatesCache(c.Request().Context())
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
return c.JSON(http.StatusCreated, map[string]interface{}{"message": "Templates created successfully", "count": len(templates)})
}
// Helper to convert model to response

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -22,11 +22,10 @@ func NewAdminUserHandler(db *gorm.DB) *AdminUserHandler {
}
// List handles GET /api/admin/users
func (h *AdminUserHandler) List(c *gin.Context) {
func (h *AdminUserHandler) List(c echo.Context) error {
var filters dto.UserFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var users []models.User
@@ -68,8 +67,7 @@ func (h *AdminUserHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch users"})
}
// Build response
@@ -78,25 +76,22 @@ func (h *AdminUserHandler) List(c *gin.Context) {
responses[i] = h.toUserResponse(&user)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/users/:id
func (h *AdminUserHandler) Get(c *gin.Context) {
func (h *AdminUserHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var user models.User
if err := h.db.Preload("Profile").Preload("OwnedResidences").First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
// Build detailed response
@@ -114,30 +109,27 @@ func (h *AdminUserHandler) Get(c *gin.Context) {
})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Create handles POST /api/admin/users
func (h *AdminUserHandler) Create(c *gin.Context) {
func (h *AdminUserHandler) Create(c echo.Context) error {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Check if username exists
var count int64
h.db.Model(&models.User{}).Where("username = ?", req.Username).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
return c.JSON(http.StatusConflict, map[string]interface{}{"error": "Username already exists"})
}
// Check if email exists
h.db.Model(&models.User{}).Where("email = ?", req.Email).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already exists"})
return
return c.JSON(http.StatusConflict, map[string]interface{}{"error": "Email already exists"})
}
user := models.User{
@@ -160,13 +152,11 @@ func (h *AdminUserHandler) Create(c *gin.Context) {
}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
if err := h.db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create user"})
}
// Create profile with phone number
@@ -178,31 +168,27 @@ func (h *AdminUserHandler) Create(c *gin.Context) {
// Reload with profile
h.db.Preload("Profile").First(&user, user.ID)
c.JSON(http.StatusCreated, h.toUserResponse(&user))
return c.JSON(http.StatusCreated, h.toUserResponse(&user))
}
// Update handles PUT /api/admin/users/:id
func (h *AdminUserHandler) Update(c *gin.Context) {
func (h *AdminUserHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var user models.User
if err := h.db.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
var req dto.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Check username uniqueness if changing
@@ -210,8 +196,7 @@ func (h *AdminUserHandler) Update(c *gin.Context) {
var count int64
h.db.Model(&models.User{}).Where("username = ? AND id != ?", *req.Username, id).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
return c.JSON(http.StatusConflict, map[string]interface{}{"error": "Username already exists"})
}
user.Username = *req.Username
}
@@ -221,8 +206,7 @@ func (h *AdminUserHandler) Update(c *gin.Context) {
var count int64
h.db.Model(&models.User{}).Where("email = ? AND id != ?", *req.Email, id).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already exists"})
return
return c.JSON(http.StatusConflict, map[string]interface{}{"error": "Email already exists"})
}
user.Email = *req.Email
}
@@ -244,14 +228,12 @@ func (h *AdminUserHandler) Update(c *gin.Context) {
}
if req.Password != nil {
if err := user.SetPassword(*req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
}
if err := h.db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update user"})
}
// Update profile fields if provided
@@ -279,52 +261,46 @@ func (h *AdminUserHandler) Update(c *gin.Context) {
}
h.db.Preload("Profile").First(&user, id)
c.JSON(http.StatusOK, h.toUserResponse(&user))
return c.JSON(http.StatusOK, h.toUserResponse(&user))
}
// Delete handles DELETE /api/admin/users/:id
func (h *AdminUserHandler) Delete(c *gin.Context) {
func (h *AdminUserHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var user models.User
if err := h.db.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user"})
}
// Soft delete - just deactivate
user.IsActive = false
if err := h.db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user"})
}
c.JSON(http.StatusOK, gin.H{"message": "User deactivated successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "User deactivated successfully"})
}
// BulkDelete handles DELETE /api/admin/users/bulk
func (h *AdminUserHandler) BulkDelete(c *gin.Context) {
func (h *AdminUserHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
// Soft delete - deactivate all
if err := h.db.Model(&models.User{}).Where("id IN ?", req.IDs).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete users"})
}
c.JSON(http.StatusOK, gin.H{"message": "Users deactivated successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Users deactivated successfully", "count": len(req.IDs)})
}
// toUserResponse converts a User model to UserResponse DTO

View File

@@ -5,7 +5,7 @@ import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/dto"
@@ -47,11 +47,10 @@ type UpdateUserProfileRequest struct {
}
// List handles GET /api/admin/user-profiles
func (h *AdminUserProfileHandler) List(c *gin.Context) {
func (h *AdminUserProfileHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
var profiles []models.UserProfile
@@ -81,8 +80,7 @@ func (h *AdminUserProfileHandler) List(c *gin.Context) {
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&profiles).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profiles"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user profiles"})
}
// Build response
@@ -91,73 +89,63 @@ func (h *AdminUserProfileHandler) List(c *gin.Context) {
responses[i] = h.toProfileResponse(&profile)
}
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/user-profiles/:id
func (h *AdminUserProfileHandler) Get(c *gin.Context) {
func (h *AdminUserProfileHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid profile ID"})
}
var profile models.UserProfile
if err := h.db.Preload("User").First(&profile, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User profile not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user profile"})
}
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
return c.JSON(http.StatusOK, h.toProfileResponse(&profile))
}
// GetByUser handles GET /api/admin/user-profiles/user/:user_id
func (h *AdminUserProfileHandler) GetByUser(c *gin.Context) {
func (h *AdminUserProfileHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var profile models.UserProfile
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&profile).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User profile not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user profile"})
}
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
return c.JSON(http.StatusOK, h.toProfileResponse(&profile))
}
// Update handles PUT /api/admin/user-profiles/:id
func (h *AdminUserProfileHandler) Update(c *gin.Context) {
func (h *AdminUserProfileHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid profile ID"})
}
var profile models.UserProfile
if err := h.db.First(&profile, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User profile not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user profile"})
}
var req UpdateUserProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if req.Verified != nil {
@@ -178,62 +166,54 @@ func (h *AdminUserProfileHandler) Update(c *gin.Context) {
} else {
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for date_of_birth, use YYYY-MM-DD"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid date format for date_of_birth, use YYYY-MM-DD"})
}
profile.DateOfBirth = &dob
}
}
if err := h.db.Save(&profile).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update user profile"})
}
h.db.Preload("User").First(&profile, id)
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
return c.JSON(http.StatusOK, h.toProfileResponse(&profile))
}
// Delete handles DELETE /api/admin/user-profiles/:id
func (h *AdminUserProfileHandler) Delete(c *gin.Context) {
func (h *AdminUserProfileHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid profile ID"})
}
var profile models.UserProfile
if err := h.db.First(&profile, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
return
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "User profile not found"})
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch user profile"})
}
if err := h.db.Delete(&profile).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profile"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profile"})
}
c.JSON(http.StatusOK, gin.H{"message": "User profile deleted successfully"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "User profile deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/user-profiles/bulk
func (h *AdminUserProfileHandler) BulkDelete(c *gin.Context) {
func (h *AdminUserProfileHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
}
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.UserProfile{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profiles"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete user profiles"})
}
c.JSON(http.StatusOK, gin.H{"message": "User profiles deleted successfully", "count": len(req.IDs)})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "User profiles deleted successfully", "count": len(req.IDs)})
}
// toProfileResponse converts a UserProfile model to UserProfileResponse

View File

@@ -6,7 +6,7 @@ import (
"net/url"
"os"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin/handlers"
@@ -27,7 +27,7 @@ type Dependencies struct {
}
// SetupRoutes configures all admin routes
func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Dependencies) {
func SetupRoutes(router *echo.Echo, db *gorm.DB, cfg *config.Config, deps *Dependencies) {
// Create repositories
adminRepo := repositories.NewAdminRepository(db)
@@ -445,7 +445,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
}
// setupAdminProxy configures reverse proxy to the Next.js admin panel
func setupAdminProxy(router *gin.Engine) {
func setupAdminProxy(router *echo.Echo) {
// Get admin panel URL from env, default to localhost:3001
// Note: In production (Dokku), Next.js runs on internal port 3001
adminURL := os.Getenv("ADMIN_PANEL_URL")
@@ -461,17 +461,19 @@ func setupAdminProxy(router *gin.Engine) {
proxy := httputil.NewSingleHostReverseProxy(target)
// Handle all /admin/* requests
router.Any("/admin/*filepath", func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
router.Any("/admin/*", func(c echo.Context) error {
proxy.ServeHTTP(c.Response(), c.Request())
return nil
})
// Also handle /admin without trailing path
router.Any("/admin", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/admin/")
router.Any("/admin", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/admin/")
})
// Proxy Next.js static assets
router.Any("/_next/*filepath", func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
router.Any("/_next/*", func(c echo.Context) error {
proxy.ServeHTTP(c.Response(), c.Request())
return nil
})
}

View File

@@ -0,0 +1,97 @@
package apperrors
import (
"fmt"
"net/http"
)
// AppError represents an application error with HTTP status and i18n key
type AppError struct {
Code int // HTTP status code
Message string // Default message (fallback if i18n key not found)
MessageKey string // i18n key for localization
Err error // Wrapped error (for internal errors)
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
if e.Message != "" {
return e.Message
}
return e.MessageKey
}
func (e *AppError) Unwrap() error {
return e.Err
}
// NotFound creates a 404 Not Found error
func NotFound(messageKey string) *AppError {
return &AppError{
Code: http.StatusNotFound,
MessageKey: messageKey,
}
}
// Forbidden creates a 403 Forbidden error
func Forbidden(messageKey string) *AppError {
return &AppError{
Code: http.StatusForbidden,
MessageKey: messageKey,
}
}
// BadRequest creates a 400 Bad Request error
func BadRequest(messageKey string) *AppError {
return &AppError{
Code: http.StatusBadRequest,
MessageKey: messageKey,
}
}
// Unauthorized creates a 401 Unauthorized error
func Unauthorized(messageKey string) *AppError {
return &AppError{
Code: http.StatusUnauthorized,
MessageKey: messageKey,
}
}
// Conflict creates a 409 Conflict error
func Conflict(messageKey string) *AppError {
return &AppError{
Code: http.StatusConflict,
MessageKey: messageKey,
}
}
// TooManyRequests creates a 429 Too Many Requests error
func TooManyRequests(messageKey string) *AppError {
return &AppError{
Code: http.StatusTooManyRequests,
MessageKey: messageKey,
}
}
// Internal creates a 500 Internal Server Error, wrapping the original error
func Internal(err error) *AppError {
return &AppError{
Code: http.StatusInternalServerError,
MessageKey: "error.internal",
Err: err,
}
}
// WithMessage adds a default message to the error (used when i18n key not found)
func (e *AppError) WithMessage(msg string) *AppError {
e.Message = msg
return e
}
// Wrap wraps an underlying error
func (e *AppError) Wrap(err error) *AppError {
e.Err = err
return e
}

View File

@@ -0,0 +1,66 @@
package apperrors
import (
"errors"
"fmt"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/i18n"
customvalidator "github.com/treytartt/casera-api/internal/validator"
)
// HTTPErrorHandler handles all errors returned from handlers in a consistent way.
// It converts AppErrors, validation errors, and Echo HTTPErrors to JSON responses.
// This is the base handler - additional service-level error handling can be added in router.go.
func HTTPErrorHandler(err error, c echo.Context) {
// Already committed? Skip
if c.Response().Committed {
return
}
// Handle AppError (our custom application errors)
var appErr *AppError
if errors.As(err, &appErr) {
message := i18n.LocalizedMessage(c, appErr.MessageKey)
// If i18n key not found (returns the key itself), use fallback message
if message == appErr.MessageKey && appErr.Message != "" {
message = appErr.Message
} else if message == appErr.MessageKey {
message = appErr.MessageKey // Use the key as last resort
}
// Log internal errors
if appErr.Err != nil {
log.Error().Err(appErr.Err).Str("message_key", appErr.MessageKey).Msg("Application error")
}
c.JSON(appErr.Code, responses.ErrorResponse{Error: message})
return
}
// Handle validation errors from go-playground/validator
var validationErrs validator.ValidationErrors
if errors.As(err, &validationErrs) {
c.JSON(http.StatusBadRequest, customvalidator.FormatValidationErrors(err))
return
}
// Handle Echo's built-in HTTPError
var httpErr *echo.HTTPError
if errors.As(err, &httpErr) {
msg := fmt.Sprintf("%v", httpErr.Message)
c.JSON(httpErr.Code, responses.ErrorResponse{Error: msg})
return
}
// Default: Internal server error (don't expose error details to client)
log.Error().Err(err).Msg("Unhandled error")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.internal"),
})
}

View File

@@ -2,47 +2,47 @@ package requests
// LoginRequest represents the login request body
type LoginRequest struct {
Username string `json:"username" binding:"required_without=Email"`
Email string `json:"email" binding:"required_without=Username,omitempty,email"`
Password string `json:"password" binding:"required,min=1"`
Username string `json:"username" validate:"required_without=Email"`
Email string `json:"email" validate:"required_without=Username,omitempty,email"`
Password string `json:"password" validate:"required,min=1"`
}
// RegisterRequest represents the registration request body
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=150"`
Email string `json:"email" binding:"required,email,max=254"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name" binding:"max=150"`
LastName string `json:"last_name" binding:"max=150"`
Username string `json:"username" validate:"required,min=3,max=150"`
Email string `json:"email" validate:"required,email,max=254"`
Password string `json:"password" validate:"required,min=8"`
FirstName string `json:"first_name" validate:"max=150"`
LastName string `json:"last_name" validate:"max=150"`
}
// VerifyEmailRequest represents the email verification request body
type VerifyEmailRequest struct {
Code string `json:"code" binding:"required,len=6"`
Code string `json:"code" validate:"required,len=6"`
}
// ForgotPasswordRequest represents the forgot password request body
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" validate:"required,email"`
}
// VerifyResetCodeRequest represents the verify reset code request body
type VerifyResetCodeRequest struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required,len=6"`
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required,len=6"`
}
// ResetPasswordRequest represents the reset password request body
type ResetPasswordRequest struct {
ResetToken string `json:"reset_token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
ResetToken string `json:"reset_token" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8"`
}
// UpdateProfileRequest represents the profile update request body
type UpdateProfileRequest struct {
Email *string `json:"email" binding:"omitempty,email,max=254"`
FirstName *string `json:"first_name" binding:"omitempty,max=150"`
LastName *string `json:"last_name" binding:"omitempty,max=150"`
Email *string `json:"email" validate:"omitempty,email,max=254"`
FirstName *string `json:"first_name" validate:"omitempty,max=150"`
LastName *string `json:"last_name" validate:"omitempty,max=150"`
}
// ResendVerificationRequest represents the resend verification email request
@@ -52,14 +52,14 @@ type ResendVerificationRequest struct {
// AppleSignInRequest represents the Apple Sign In request body
type AppleSignInRequest struct {
IDToken string `json:"id_token" binding:"required"`
UserID string `json:"user_id" binding:"required"` // Apple's sub claim
Email *string `json:"email"` // May be nil or private relay
IDToken string `json:"id_token" validate:"required"`
UserID string `json:"user_id" validate:"required"` // Apple's sub claim
Email *string `json:"email"` // May be nil or private relay
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
}
// GoogleSignInRequest represents the Google Sign In request body
type GoogleSignInRequest struct {
IDToken string `json:"id_token" binding:"required"` // Google ID token from Credential Manager
IDToken string `json:"id_token" validate:"required"` // Google ID token from Credential Manager
}

View File

@@ -3,16 +3,16 @@ package requests
// CreateContractorRequest represents the request to create a contractor
type CreateContractorRequest struct {
ResidenceID *uint `json:"residence_id"`
Name string `json:"name" binding:"required,min=1,max=200"`
Company string `json:"company" binding:"max=200"`
Phone string `json:"phone" binding:"max=20"`
Email string `json:"email" binding:"omitempty,email,max=254"`
Website string `json:"website" binding:"max=200"`
Name string `json:"name" validate:"required,min=1,max=200"`
Company string `json:"company" validate:"max=200"`
Phone string `json:"phone" validate:"max=20"`
Email string `json:"email" validate:"omitempty,email,max=254"`
Website string `json:"website" validate:"max=200"`
Notes string `json:"notes"`
StreetAddress string `json:"street_address" binding:"max=255"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
StreetAddress string `json:"street_address" validate:"max=255"`
City string `json:"city" validate:"max=100"`
StateProvince string `json:"state_province" validate:"max=100"`
PostalCode string `json:"postal_code" validate:"max=20"`
SpecialtyIDs []uint `json:"specialty_ids"`
Rating *float64 `json:"rating"`
IsFavorite *bool `json:"is_favorite"`
@@ -20,16 +20,16 @@ type CreateContractorRequest struct {
// UpdateContractorRequest represents the request to update a contractor
type UpdateContractorRequest struct {
Name *string `json:"name" binding:"omitempty,min=1,max=200"`
Company *string `json:"company" binding:"omitempty,max=200"`
Phone *string `json:"phone" binding:"omitempty,max=20"`
Email *string `json:"email" binding:"omitempty,email,max=254"`
Website *string `json:"website" binding:"omitempty,max=200"`
Name *string `json:"name" validate:"omitempty,min=1,max=200"`
Company *string `json:"company" validate:"omitempty,max=200"`
Phone *string `json:"phone" validate:"omitempty,max=20"`
Email *string `json:"email" validate:"omitempty,email,max=254"`
Website *string `json:"website" validate:"omitempty,max=200"`
Notes *string `json:"notes"`
StreetAddress *string `json:"street_address" binding:"omitempty,max=255"`
City *string `json:"city" binding:"omitempty,max=100"`
StateProvince *string `json:"state_province" binding:"omitempty,max=100"`
PostalCode *string `json:"postal_code" binding:"omitempty,max=20"`
StreetAddress *string `json:"street_address" validate:"omitempty,max=255"`
City *string `json:"city" validate:"omitempty,max=100"`
StateProvince *string `json:"state_province" validate:"omitempty,max=100"`
PostalCode *string `json:"postal_code" validate:"omitempty,max=20"`
SpecialtyIDs []uint `json:"specialty_ids"`
Rating *float64 `json:"rating"`
IsFavorite *bool `json:"is_favorite"`

View File

@@ -10,38 +10,38 @@ import (
// CreateDocumentRequest represents the request to create a document
type CreateDocumentRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
Title string `json:"title" binding:"required,min=1,max=200"`
ResidenceID uint `json:"residence_id" validate:"required"`
Title string `json:"title" validate:"required,min=1,max=200"`
Description string `json:"description"`
DocumentType models.DocumentType `json:"document_type"`
FileURL string `json:"file_url" binding:"max=500"`
FileName string `json:"file_name" binding:"max=255"`
FileURL string `json:"file_url" validate:"max=500"`
FileName string `json:"file_name" validate:"max=255"`
FileSize *int64 `json:"file_size"`
MimeType string `json:"mime_type" binding:"max=100"`
MimeType string `json:"mime_type" validate:"max=100"`
PurchaseDate *time.Time `json:"purchase_date"`
ExpiryDate *time.Time `json:"expiry_date"`
PurchasePrice *decimal.Decimal `json:"purchase_price"`
Vendor string `json:"vendor" binding:"max=200"`
SerialNumber string `json:"serial_number" binding:"max=100"`
ModelNumber string `json:"model_number" binding:"max=100"`
Vendor string `json:"vendor" validate:"max=200"`
SerialNumber string `json:"serial_number" validate:"max=100"`
ModelNumber string `json:"model_number" validate:"max=100"`
TaskID *uint `json:"task_id"`
ImageURLs []string `json:"image_urls"` // Multiple image URLs
}
// UpdateDocumentRequest represents the request to update a document
type UpdateDocumentRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=200"`
Title *string `json:"title" validate:"omitempty,min=1,max=200"`
Description *string `json:"description"`
DocumentType *models.DocumentType `json:"document_type"`
FileURL *string `json:"file_url" binding:"omitempty,max=500"`
FileName *string `json:"file_name" binding:"omitempty,max=255"`
FileURL *string `json:"file_url" validate:"omitempty,max=500"`
FileName *string `json:"file_name" validate:"omitempty,max=255"`
FileSize *int64 `json:"file_size"`
MimeType *string `json:"mime_type" binding:"omitempty,max=100"`
MimeType *string `json:"mime_type" validate:"omitempty,max=100"`
PurchaseDate *time.Time `json:"purchase_date"`
ExpiryDate *time.Time `json:"expiry_date"`
PurchasePrice *decimal.Decimal `json:"purchase_price"`
Vendor *string `json:"vendor" binding:"omitempty,max=200"`
SerialNumber *string `json:"serial_number" binding:"omitempty,max=100"`
ModelNumber *string `json:"model_number" binding:"omitempty,max=100"`
Vendor *string `json:"vendor" validate:"omitempty,max=200"`
SerialNumber *string `json:"serial_number" validate:"omitempty,max=100"`
ModelNumber *string `json:"model_number" validate:"omitempty,max=100"`
TaskID *uint `json:"task_id"`
}

View File

@@ -8,14 +8,14 @@ import (
// CreateResidenceRequest represents the request to create a residence
type CreateResidenceRequest struct {
Name string `json:"name" binding:"required,min=1,max=200"`
Name string `json:"name" validate:"required,min=1,max=200"`
PropertyTypeID *uint `json:"property_type_id"`
StreetAddress string `json:"street_address" binding:"max=255"`
ApartmentUnit string `json:"apartment_unit" binding:"max=50"`
City string `json:"city" binding:"max=100"`
StateProvince string `json:"state_province" binding:"max=100"`
PostalCode string `json:"postal_code" binding:"max=20"`
Country string `json:"country" binding:"max=100"`
StreetAddress string `json:"street_address" validate:"max=255"`
ApartmentUnit string `json:"apartment_unit" validate:"max=50"`
City string `json:"city" validate:"max=100"`
StateProvince string `json:"state_province" validate:"max=100"`
PostalCode string `json:"postal_code" validate:"max=20"`
Country string `json:"country" validate:"max=100"`
Bedrooms *int `json:"bedrooms"`
Bathrooms *decimal.Decimal `json:"bathrooms"`
SquareFootage *int `json:"square_footage"`
@@ -29,14 +29,14 @@ type CreateResidenceRequest struct {
// UpdateResidenceRequest represents the request to update a residence
type UpdateResidenceRequest struct {
Name *string `json:"name" binding:"omitempty,min=1,max=200"`
Name *string `json:"name" validate:"omitempty,min=1,max=200"`
PropertyTypeID *uint `json:"property_type_id"`
StreetAddress *string `json:"street_address" binding:"omitempty,max=255"`
ApartmentUnit *string `json:"apartment_unit" binding:"omitempty,max=50"`
City *string `json:"city" binding:"omitempty,max=100"`
StateProvince *string `json:"state_province" binding:"omitempty,max=100"`
PostalCode *string `json:"postal_code" binding:"omitempty,max=20"`
Country *string `json:"country" binding:"omitempty,max=100"`
StreetAddress *string `json:"street_address" validate:"omitempty,max=255"`
ApartmentUnit *string `json:"apartment_unit" validate:"omitempty,max=50"`
City *string `json:"city" validate:"omitempty,max=100"`
StateProvince *string `json:"state_province" validate:"omitempty,max=100"`
PostalCode *string `json:"postal_code" validate:"omitempty,max=20"`
Country *string `json:"country" validate:"omitempty,max=100"`
Bedrooms *int `json:"bedrooms"`
Bathrooms *decimal.Decimal `json:"bathrooms"`
SquareFootage *int `json:"square_footage"`
@@ -50,7 +50,7 @@ type UpdateResidenceRequest struct {
// JoinWithCodeRequest represents the request to join a residence via share code
type JoinWithCodeRequest struct {
Code string `json:"code" binding:"required,len=6"`
Code string `json:"code" validate:"required,len=6"`
}
// GenerateShareCodeRequest represents the request to generate a share code

View File

@@ -54,8 +54,8 @@ func (fd *FlexibleDate) ToTimePtr() *time.Time {
// CreateTaskRequest represents the request to create a task
type CreateTaskRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
Title string `json:"title" binding:"required,min=1,max=200"`
ResidenceID uint `json:"residence_id" validate:"required"`
Title string `json:"title" validate:"required,min=1,max=200"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
@@ -70,7 +70,7 @@ type CreateTaskRequest struct {
// UpdateTaskRequest represents the request to update a task
type UpdateTaskRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=200"`
Title *string `json:"title" validate:"omitempty,min=1,max=200"`
Description *string `json:"description"`
CategoryID *uint `json:"category_id"`
PriorityID *uint `json:"priority_id"`
@@ -86,7 +86,7 @@ type UpdateTaskRequest struct {
// CreateTaskCompletionRequest represents the request to create a task completion
type CreateTaskCompletionRequest struct {
TaskID uint `json:"task_id" binding:"required"`
TaskID uint `json:"task_id" validate:"required"`
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
Notes string `json:"notes"`
ActualCost *decimal.Decimal `json:"actual_cost"`
@@ -96,6 +96,6 @@ type CreateTaskCompletionRequest struct {
// CompletionImageInput represents an image to add to a completion
type CompletionImageInput struct {
ImageURL string `json:"image_url" binding:"required"`
ImageURL string `json:"image_url" validate:"required"`
Caption string `json:"caption"`
}

View File

@@ -0,0 +1,45 @@
package echohelpers
import (
"strconv"
"github.com/labstack/echo/v4"
)
// DefaultQuery returns query param with default if not present
func DefaultQuery(c echo.Context, key, defaultValue string) string {
val := c.QueryParam(key)
if val == "" {
return defaultValue
}
return val
}
// ParseUintParam parses a path parameter as uint
func ParseUintParam(c echo.Context, name string) (uint, error) {
val, err := strconv.ParseUint(c.Param(name), 10, 32)
if err != nil {
return 0, err
}
return uint(val), nil
}
// ParseIntParam parses a path parameter as int
func ParseIntParam(c echo.Context, name string) (int, error) {
val, err := strconv.Atoi(c.Param(name))
if err != nil {
return 0, err
}
return val, nil
}
// BindAndValidate binds and validates the request body
func BindAndValidate(c echo.Context, req interface{}) error {
if err := c.Bind(req); err != nil {
return err
}
if err := c.Validate(req); err != nil {
return err
}
return nil
}

View File

@@ -4,14 +4,15 @@ import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/validator"
)
// AuthHandler handles authentication endpoints
@@ -43,65 +44,38 @@ func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthServic
}
// Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c *gin.Context) {
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.Login(&req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.invalid_credentials")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
}
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// Register handles POST /api/auth/register/
func (h *AuthHandler) Register(c *gin.Context) {
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, confirmationCode, err := h.authService.Register(&req)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrUsernameTaken) {
message = i18n.LocalizedMessage(c, "error.username_taken")
} else if errors.Is(err, services.ErrEmailTaken) {
message = i18n.LocalizedMessage(c, "error.email_taken")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.registration_failed")
log.Error().Err(err).Msg("Registration failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Registration failed")
return err
}
// Send welcome email with confirmation code (async)
@@ -113,15 +87,14 @@ func (h *AuthHandler) Register(c *gin.Context) {
}()
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// Logout handles POST /api/auth/logout/
func (h *AuthHandler) Logout(c *gin.Context) {
func (h *AuthHandler) Logout(c echo.Context) error {
token := middleware.GetAuthToken(c)
if token == "" {
c.JSON(http.StatusUnauthorized, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.not_authenticated")})
return
return apperrors.Unauthorized("error.not_authenticated")
}
// Invalidate token in database
@@ -131,101 +104,73 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// Invalidate token in cache
if h.cache != nil {
if err := h.cache.InvalidateAuthToken(c.Request.Context(), token); err != nil {
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate token in cache")
}
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.logged_out")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) CurrentUser(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
response, err := h.authService.GetCurrentUser(user.ID)
if err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_get_user")})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UpdateProfile handles PUT/PATCH /api/auth/profile/
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) UpdateProfile(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.UpdateProfile(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrEmailTaken) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_taken")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_update_profile")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile")
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// VerifyEmail handles POST /api/auth/verify-email/
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) VerifyEmail(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.VerifyEmail(user.ID, req.Code)
err = h.authService.VerifyEmail(user.ID, req.Code)
if err != nil {
status := http.StatusBadRequest
message := err.Error()
if errors.Is(err, services.ErrInvalidCode) {
message = i18n.LocalizedMessage(c, "error.invalid_verification_code")
} else if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrAlreadyVerified) {
message = i18n.LocalizedMessage(c, "error.email_already_verified")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.verification_failed")
log.Error().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
return err
}
// Send post-verification welcome email with tips (async)
@@ -237,29 +182,23 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: i18n.LocalizedMessage(c, "message.email_verified"),
return c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: "Email verified successfully",
Verified: true,
})
}
// ResendVerification handles POST /api/auth/resend-verification/
func (h *AuthHandler) ResendVerification(c *gin.Context) {
user := middleware.MustGetAuthUser(c)
if user == nil {
return
func (h *AuthHandler) ResendVerification(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
code, err := h.authService.ResendVerificationCode(user.ID)
if err != nil {
if errors.Is(err, services.ErrAlreadyVerified) {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.email_already_verified")})
return
}
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{Error: i18n.LocalizedMessage(c, "error.failed_to_resend_verification")})
return
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
return err
}
// Send verification email (async)
@@ -271,33 +210,29 @@ func (h *AuthHandler) ResendVerification(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.verification_email_sent")})
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
}
// ForgotPassword handles POST /api/auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
func (h *AuthHandler) ForgotPassword(c echo.Context) error {
var req requests.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
code, user, err := h.authService.ForgotPassword(req.Email)
if err != nil {
if errors.Is(err, services.ErrRateLimitExceeded) {
c.JSON(http.StatusTooManyRequests, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.rate_limit_exceeded"),
})
return
var appErr *apperrors.AppError
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
// Only reveal rate limit errors
return err
}
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
// Don't reveal errors to prevent email enumeration
// Don't reveal other errors to prevent email enumeration
}
// Send password reset email (async) - only if user found
@@ -310,116 +245,82 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
}
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_email_sent"),
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "Password reset email sent",
})
}
// VerifyResetCode handles POST /api/auth/verify-reset-code/
func (h *AuthHandler) VerifyResetCode(c *gin.Context) {
func (h *AuthHandler) VerifyResetCode(c echo.Context) error {
var req requests.VerifyResetCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_verification_code")
if errors.Is(err, services.ErrCodeExpired) {
message = i18n.LocalizedMessage(c, "error.verification_code_expired")
} else if errors.Is(err, services.ErrRateLimitExceeded) {
status = http.StatusTooManyRequests
message = i18n.LocalizedMessage(c, "error.too_many_attempts")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed")
return err
}
c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: i18n.LocalizedMessage(c, "message.reset_code_verified"),
return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: "Reset code verified",
ResetToken: resetToken,
})
}
// ResetPassword handles POST /api/auth/reset-password/
func (h *AuthHandler) ResetPassword(c *gin.Context) {
func (h *AuthHandler) ResetPassword(c echo.Context) error {
var req requests.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.ResetPassword(req.ResetToken, req.NewPassword)
if err != nil {
status := http.StatusBadRequest
message := i18n.LocalizedMessage(c, "error.invalid_reset_token")
if errors.Is(err, services.ErrInvalidResetToken) {
message = i18n.LocalizedMessage(c, "error.invalid_reset_token")
} else {
status = http.StatusInternalServerError
message = i18n.LocalizedMessage(c, "error.password_reset_failed")
log.Error().Err(err).Msg("Password reset failed")
}
c.JSON(status, responses.ErrorResponse{Error: message})
return
log.Debug().Err(err).Msg("Password reset failed")
return err
}
c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: i18n.LocalizedMessage(c, "message.password_reset_success"),
return c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: "Password reset successful",
})
}
// AppleSignIn handles POST /api/auth/apple-sign-in/
func (h *AuthHandler) AppleSignIn(c *gin.Context) {
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
var req requests.AppleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.appleAuthService == nil {
log.Error().Msg("Apple auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.apple_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.apple_signin_not_configured",
}
}
response, err := h.authService.AppleSignIn(c.Request.Context(), h.appleAuthService, &req)
response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.apple_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrAppleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_apple_token")
// Check for legacy Apple Sign In error (not yet migrated)
if errors.Is(err, services.ErrAppleSignInFailed) {
log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_apple_token")
}
log.Debug().Err(err).Msg("Apple Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -431,44 +332,37 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
var req requests.GoogleSignInRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.invalid_request_body"),
Details: map[string]string{
"validation": err.Error(),
},
})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.googleAuthService == nil {
log.Error().Msg("Google auth service not configured")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.google_signin_not_configured"),
})
return
return &apperrors.AppError{
Code: 500,
MessageKey: "error.google_signin_not_configured",
}
}
response, err := h.authService.GoogleSignIn(c.Request.Context(), h.googleAuthService, &req)
response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req)
if err != nil {
status := http.StatusUnauthorized
message := i18n.LocalizedMessage(c, "error.google_signin_failed")
if errors.Is(err, services.ErrUserInactive) {
message = i18n.LocalizedMessage(c, "error.account_inactive")
} else if errors.Is(err, services.ErrGoogleSignInFailed) {
message = i18n.LocalizedMessage(c, "error.invalid_google_token")
// Check for legacy Google Sign In error (not yet migrated)
if errors.Is(err, services.ErrGoogleSignInFailed) {
log.Debug().Err(err).Msg("Google Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_google_token")
}
log.Debug().Err(err).Msg("Google Sign In failed")
c.JSON(status, responses.ErrorResponse{Error: message})
return
return err
}
// Send welcome email for new users (async)
@@ -480,5 +374,5 @@ func (h *AuthHandler) GoogleSignIn(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,7 +17,7 @@ import (
"github.com/treytartt/casera-api/internal/testutil"
)
func setupAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *repositories.UserRepository) {
func setupAuthHandler(t *testing.T) (*AuthHandler, *echo.Echo, *repositories.UserRepository) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
@@ -30,14 +30,14 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *repositories.Us
}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil) // No email or cache for tests
router := testutil.SetupTestRouter()
return handler, router, userRepo
e := testutil.SetupTestRouter()
return handler, e, userRepo
}
func TestAuthHandler_Register(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/register/", handler.Register)
t.Run("successful registration", func(t *testing.T) {
req := requests.RegisterRequest{
@@ -48,7 +48,7 @@ func TestAuthHandler_Register(t *testing.T) {
LastName: "User",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -73,7 +73,7 @@ func TestAuthHandler_Register(t *testing.T) {
// Missing email and password
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
@@ -88,7 +88,7 @@ func TestAuthHandler_Register(t *testing.T) {
Password: "short", // Less than 8 chars
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
@@ -100,13 +100,13 @@ func TestAuthHandler_Register(t *testing.T) {
Email: "unique1@test.com",
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same username
req.Email = "unique2@test.com"
w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Username already taken")
@@ -119,13 +119,13 @@ func TestAuthHandler_Register(t *testing.T) {
Email: "duplicate@test.com",
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same email
req.Username = "user2"
w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Email already registered")
@@ -133,10 +133,10 @@ func TestAuthHandler_Register(t *testing.T) {
}
func TestAuthHandler_Login(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/login/", handler.Login)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
// Create a test user
registerReq := requests.RegisterRequest{
@@ -146,7 +146,7 @@ func TestAuthHandler_Login(t *testing.T) {
FirstName: "Test",
LastName: "User",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
t.Run("successful login with username", func(t *testing.T) {
@@ -155,7 +155,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -177,7 +177,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
@@ -188,7 +188,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "wrongpassword",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
@@ -202,7 +202,7 @@ func TestAuthHandler_Login(t *testing.T) {
Password: "password123",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
@@ -213,14 +213,14 @@ func TestAuthHandler_Login(t *testing.T) {
// Missing password
}
w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_CurrentUser(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "password123")
@@ -229,12 +229,12 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
userRepo.Update(user)
// Set up route with mock auth middleware
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/me/", handler.CurrentUser)
t.Run("get current user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/auth/me/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/auth/me/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -248,13 +248,13 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
}
func TestAuthHandler_UpdateProfile(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "password123")
userRepo.Update(user)
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/profile/", handler.UpdateProfile)
@@ -266,7 +266,7 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
LastName: &lastName,
}
w := testutil.MakeRequest(router, "PUT", "/api/auth/profile/", req, "test-token")
w := testutil.MakeRequest(e, "PUT", "/api/auth/profile/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -280,10 +280,10 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
}
func TestAuthHandler_ForgotPassword(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/forgot-password/", handler.ForgotPassword)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
// Create a test user
registerReq := requests.RegisterRequest{
@@ -291,14 +291,14 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
Email: "forgot@test.com",
Password: "password123",
}
testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "")
testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
t.Run("forgot password with valid email", func(t *testing.T) {
req := requests.ForgotPasswordRequest{
Email: "forgot@test.com",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Always returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -312,7 +312,7 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
Email: "nonexistent@test.com",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Still returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -320,18 +320,18 @@ func TestAuthHandler_ForgotPassword(t *testing.T) {
}
func TestAuthHandler_Logout(t *testing.T) {
handler, router, userRepo := setupAuthHandler(t)
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "logouttest", "logout@test.com", "password123")
userRepo.Update(user)
authGroup := router.Group("/api/auth")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/logout/", handler.Logout)
t.Run("successful logout", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", "/api/auth/logout/", nil, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/auth/logout/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -341,10 +341,10 @@ func TestAuthHandler_Logout(t *testing.T) {
}
func TestAuthHandler_JSONResponses(t *testing.T) {
handler, router, _ := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
router.POST("/api/auth/register/", handler.Register)
router.POST("/api/auth/login/", handler.Login)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
t.Run("register response has correct JSON structure", func(t *testing.T) {
req := requests.RegisterRequest{
@@ -355,7 +355,7 @@ func TestAuthHandler_JSONResponses(t *testing.T) {
LastName: "Test",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -393,7 +393,7 @@ func TestAuthHandler_JSONResponses(t *testing.T) {
"username": "test",
}
w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "")
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)

View File

@@ -1,14 +1,13 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -25,190 +24,130 @@ func NewContractorHandler(contractorService *services.ContractorService) *Contra
}
// ListContractors handles GET /api/contractors/
func (h *ContractorHandler) ListContractors(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ListContractors(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.contractorService.ListContractors(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetContractor handles GET /api/contractors/:id/
func (h *ContractorHandler) GetContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) GetContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.GetContractor(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateContractor handles POST /api/contractors/
func (h *ContractorHandler) CreateContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) CreateContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.contractorService.CreateContractor(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateContractor handles PUT/PATCH /api/contractors/:id/
func (h *ContractorHandler) UpdateContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) UpdateContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
var req requests.UpdateContractorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.contractorService.UpdateContractor(uint(contractorID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteContractor handles DELETE /api/contractors/:id/
func (h *ContractorHandler) DeleteContractor(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) DeleteContractor(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
err = h.contractorService.DeleteContractor(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.contractor_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Contractor deleted successfully"})
}
// ToggleFavorite handles POST /api/contractors/:id/toggle-favorite/
func (h *ContractorHandler) ToggleFavorite(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ToggleFavorite(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.ToggleFavorite(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetContractorTasks handles GET /api/contractors/:id/tasks/
func (h *ContractorHandler) GetContractorTasks(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) GetContractorTasks(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
contractorID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_contractor_id")})
return
return apperrors.BadRequest("error.invalid_contractor_id")
}
response, err := h.contractorService.GetContractorTasks(uint(contractorID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrContractorNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_not_found")})
case errors.Is(err, services.ErrContractorAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.contractor_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListContractorsByResidence handles GET /api/contractors/by-residence/:residence_id/
func (h *ContractorHandler) ListContractorsByResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ContractorHandler) ListContractorsByResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.contractorService.ListContractorsByResidence(uint(residenceID), user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetSpecialties handles GET /api/contractors/specialties/
func (h *ContractorHandler) GetSpecialties(c *gin.Context) {
func (h *ContractorHandler) GetSpecialties(c echo.Context) error {
specialties, err := h.contractorService.GetSpecialties()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, specialties)
return c.JSON(http.StatusOK, specialties)
}

View File

@@ -1,18 +1,17 @@
package handlers
import (
"errors"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -33,101 +32,86 @@ func NewDocumentHandler(documentService *services.DocumentService, storageServic
}
// ListDocuments handles GET /api/documents/
func (h *DocumentHandler) ListDocuments(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.documentService.ListDocuments(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetDocument handles GET /api/documents/:id/
func (h *DocumentHandler) GetDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) GetDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.GetDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListWarranties handles GET /api/documents/warranties/
func (h *DocumentHandler) ListWarranties(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ListWarranties(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.documentService.ListWarranties(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateDocument handles POST /api/documents/
// Supports both JSON and multipart form data (for file uploads)
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) CreateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateDocumentRequest
contentType := c.GetHeader("Content-Type")
contentType := c.Request().Header.Get("Content-Type")
// Check if this is a multipart form request (file upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")})
return
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse residence_id (required)
residenceIDStr := c.PostForm("residence_id")
residenceIDStr := c.FormValue("residence_id")
if residenceIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_id_required")})
return
return apperrors.BadRequest("error.residence_id_required")
}
residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
req.ResidenceID = uint(residenceID)
// Parse title (required)
req.Title = c.PostForm("title")
req.Title = c.FormValue("title")
if req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.title_required")})
return
return apperrors.BadRequest("error.title_required")
}
// Parse optional fields
req.Description = c.PostForm("description")
req.Vendor = c.PostForm("vendor")
req.SerialNumber = c.PostForm("serial_number")
req.ModelNumber = c.PostForm("model_number")
req.Description = c.FormValue("description")
req.Vendor = c.FormValue("vendor")
req.SerialNumber = c.FormValue("serial_number")
req.ModelNumber = c.FormValue("model_number")
// Parse document_type
if docType := c.PostForm("document_type"); docType != "" {
if docType := c.FormValue("document_type"); docType != "" {
dt := models.DocumentType(docType)
req.DocumentType = dt
}
// Parse task_id (optional)
if taskIDStr := c.PostForm("task_id"); taskIDStr != "" {
if taskIDStr := c.FormValue("task_id"); taskIDStr != "" {
if taskID, err := strconv.ParseUint(taskIDStr, 10, 32); err == nil {
tid := uint(taskID)
req.TaskID = &tid
@@ -135,14 +119,14 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
// Parse purchase_price (optional)
if priceStr := c.PostForm("purchase_price"); priceStr != "" {
if priceStr := c.FormValue("purchase_price"); priceStr != "" {
if price, err := decimal.NewFromString(priceStr); err == nil {
req.PurchasePrice = &price
}
}
// Parse purchase_date (optional)
if dateStr := c.PostForm("purchase_date"); dateStr != "" {
if dateStr := c.FormValue("purchase_date"); dateStr != "" {
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
req.PurchaseDate = &t
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
@@ -151,7 +135,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
// Parse expiry_date (optional)
if dateStr := c.PostForm("expiry_date"); dateStr != "" {
if dateStr := c.FormValue("expiry_date"); dateStr != "" {
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
req.ExpiryDate = &t
} else if t, err := time.Parse("2006-01-02", dateStr); err == nil {
@@ -171,8 +155,7 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
if uploadedFile != nil && h.storageService != nil {
result, err := h.storageService.Upload(uploadedFile, "documents")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_file")})
return
return apperrors.BadRequest("error.failed_to_upload_file")
}
req.FileURL = result.URL
req.FileName = result.FileName
@@ -182,122 +165,79 @@ func (h *DocumentHandler) CreateDocument(c *gin.Context) {
}
} else {
// Standard JSON request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
}
response, err := h.documentService.CreateDocument(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateDocument handles PUT/PATCH /api/documents/:id/
func (h *DocumentHandler) UpdateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) UpdateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
var req requests.UpdateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.documentService.UpdateDocument(uint(documentID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteDocument handles DELETE /api/documents/:id/
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) DeleteDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
err = h.documentService.DeleteDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deleted successfully"})
}
// ActivateDocument handles POST /api/documents/:id/activate/
func (h *DocumentHandler) ActivateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) ActivateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.ActivateDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_activated"), "document": response})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document activated successfully", "document": response})
}
// DeactivateDocument handles POST /api/documents/:id/deactivate/
func (h *DocumentHandler) DeactivateDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *DocumentHandler) DeactivateDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_document_id")})
return
return apperrors.BadRequest("error.invalid_document_id")
}
response, err := h.documentService.DeactivateDocument(uint(documentID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrDocumentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.document_not_found")})
case errors.Is(err, services.ErrDocumentAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.document_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.document_deactivated"), "document": response})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response})
}

View File

@@ -1,13 +1,13 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
@@ -39,132 +39,117 @@ func NewMediaHandler(
// ServeDocument serves a document file with access control
// GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeDocument(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get document
doc, err := h.documentRepo.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
return
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(doc.FileURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
// Set caching headers (private, 1 hour)
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeDocumentImage serves a document image with access control
// GET /api/media/document-image/:id
func (h *MediaHandler) ServeDocumentImage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get document image
img, err := h.documentRepo.FindImageByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
return
return apperrors.NotFound("error.image_not_found")
}
// Get parent document to check residence access
doc, err := h.documentRepo.FindByID(img.DocumentID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parent document not found"})
return
return apperrors.NotFound("error.document_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// ServeCompletionImage serves a task completion image with access control
// GET /api/media/completion-image/:id
func (h *MediaHandler) ServeCompletionImage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
// Get completion image
img, err := h.taskRepo.FindCompletionImageByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"})
return
return apperrors.NotFound("error.image_not_found")
}
// Get the completion to get the task
completion, err := h.taskRepo.FindCompletionByID(img.CompletionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
return
return apperrors.NotFound("error.completion_not_found")
}
// Get task to check residence access
task, err := h.taskRepo.FindByID(completion.TaskID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Task not found"})
return
return apperrors.NotFound("error.task_not_found")
}
// Check access to residence
hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID)
if err != nil || !hasAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
return apperrors.Forbidden("error.access_denied")
}
// Serve the file
filePath := h.resolveFilePath(img.ImageURL)
if filePath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
return
return apperrors.NotFound("error.file_not_found")
}
c.Header("Cache-Control", "private, max-age=3600")
c.File(filePath)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
return c.File(filePath)
}
// resolveFilePath converts a stored URL to an actual file path

View File

@@ -1,13 +1,12 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -24,17 +23,17 @@ func NewNotificationHandler(notificationService *services.NotificationService) *
}
// ListNotifications handles GET /api/notifications/
func (h *NotificationHandler) ListNotifications(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) ListNotifications(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if o := c.QueryParam("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
@@ -42,157 +41,132 @@ func (h *NotificationHandler) ListNotifications(c *gin.Context) {
notifications, err := h.notificationService.GetNotifications(user.ID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(notifications),
"results": notifications,
})
}
// GetUnreadCount handles GET /api/notifications/unread-count/
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) GetUnreadCount(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
count, err := h.notificationService.GetUnreadCount(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"unread_count": count})
return c.JSON(http.StatusOK, map[string]interface{}{"unread_count": count})
}
// MarkAsRead handles POST /api/notifications/:id/read/
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) MarkAsRead(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
notificationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_notification_id")})
return
return apperrors.BadRequest("error.invalid_notification_id")
}
err = h.notificationService.MarkAsRead(uint(notificationID), user.ID)
if err != nil {
if errors.Is(err, services.ErrNotificationNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.notification_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.notification_marked_read")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.notification_marked_read"})
}
// MarkAllAsRead handles POST /api/notifications/mark-all-read/
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) MarkAllAsRead(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
err := h.notificationService.MarkAllAsRead(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.all_notifications_marked_read")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.all_notifications_marked_read"})
}
// GetPreferences handles GET /api/notifications/preferences/
func (h *NotificationHandler) GetPreferences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) GetPreferences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
prefs, err := h.notificationService.GetPreferences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, prefs)
return c.JSON(http.StatusOK, prefs)
}
// UpdatePreferences handles PUT/PATCH /api/notifications/preferences/
func (h *NotificationHandler) UpdatePreferences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) UpdatePreferences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.UpdatePreferencesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
prefs, err := h.notificationService.UpdatePreferences(user.ID, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, prefs)
return c.JSON(http.StatusOK, prefs)
}
// RegisterDevice handles POST /api/notifications/devices/
func (h *NotificationHandler) RegisterDevice(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) RegisterDevice(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.RegisterDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
device, err := h.notificationService.RegisterDevice(user.ID, &req)
if err != nil {
if errors.Is(err, services.ErrInvalidPlatform) {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, device)
return c.JSON(http.StatusCreated, device)
}
// ListDevices handles GET /api/notifications/devices/
func (h *NotificationHandler) ListDevices(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) ListDevices(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
devices, err := h.notificationService.ListDevices(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, devices)
return c.JSON(http.StatusOK, devices)
}
// DeleteDevice handles DELETE /api/notifications/devices/:id/
func (h *NotificationHandler) DeleteDevice(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
deviceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_device_id")})
return
return apperrors.BadRequest("error.invalid_device_id")
}
platform := c.Query("platform")
platform := c.QueryParam("platform")
if platform == "" {
platform = "ios" // Default to iOS
}
err = h.notificationService.DeleteDevice(uint(deviceID), platform, user.ID)
if err != nil {
if errors.Is(err, services.ErrInvalidPlatform) {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_platform")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.device_removed")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_removed"})
}

View File

@@ -1,17 +1,18 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/validator"
)
// ResidenceHandler handles residence-related HTTP requests
@@ -31,343 +32,252 @@ func NewResidenceHandler(residenceService *services.ResidenceService, pdfService
}
// ListResidences handles GET /api/residences/
func (h *ResidenceHandler) ListResidences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) ListResidences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.residenceService.ListResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetMyResidences handles GET /api/residences/my-residences/
func (h *ResidenceHandler) GetMyResidences(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetMyResidences(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
response, err := h.residenceService.GetMyResidences(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetSummary handles GET /api/residences/summary/
// Returns just the task statistics summary without full residence data
func (h *ResidenceHandler) GetSummary(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetSummary(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
summary, err := h.residenceService.GetSummary(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, summary)
return c.JSON(http.StatusOK, summary)
}
// GetResidence handles GET /api/residences/:id/
func (h *ResidenceHandler) GetResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.residenceService.GetResidence(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateResidence handles POST /api/residences/
func (h *ResidenceHandler) CreateResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) CreateResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.CreateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.residenceService.CreateResidence(&req, user.ID)
if err != nil {
if errors.Is(err, services.ErrPropertiesLimitReached) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.properties_limit_reached")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateResidence handles PUT/PATCH /api/residences/:id/
func (h *ResidenceHandler) UpdateResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) UpdateResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.UpdateResidenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.residenceService.UpdateResidence(uint(residenceID), user.ID, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteResidence handles DELETE /api/residences/:id/
func (h *ResidenceHandler) DeleteResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) DeleteResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
response, err := h.residenceService.DeleteResidence(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GenerateShareCode handles POST /api/residences/:id/generate-share-code/
func (h *ResidenceHandler) GenerateShareCode(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateShareCode(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.GenerateShareCodeRequest
// Request body is optional
c.ShouldBindJSON(&req)
c.Bind(&req)
response, err := h.residenceService.GenerateShareCode(uint(residenceID), user.ID, req.ExpiresInHours)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GenerateSharePackage handles POST /api/residences/:id/generate-share-package/
// Returns a share code with metadata for creating a .casera package file
func (h *ResidenceHandler) GenerateSharePackage(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateSharePackage(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
var req requests.GenerateShareCodeRequest
// Request body is optional (for expires_in_hours)
c.ShouldBindJSON(&req)
c.Bind(&req)
response, err := h.residenceService.GenerateSharePackage(uint(residenceID), user.ID, req.ExpiresInHours)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// JoinWithCode handles POST /api/residences/join-with-code/
func (h *ResidenceHandler) JoinWithCode(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) JoinWithCode(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.JoinWithCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.residenceService.JoinWithCode(req.Code, user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrShareCodeInvalid):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_invalid")})
case errors.Is(err, services.ErrShareCodeExpired):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.share_code_expired")})
case errors.Is(err, services.ErrUserAlreadyMember):
c.JSON(http.StatusConflict, gin.H{"error": i18n.LocalizedMessage(c, "error.user_already_member")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetResidenceUsers handles GET /api/residences/:id/users/
func (h *ResidenceHandler) GetResidenceUsers(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GetResidenceUsers(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
users, err := h.residenceService.GetResidenceUsers(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, users)
return c.JSON(http.StatusOK, users)
}
// RemoveResidenceUser handles DELETE /api/residences/:id/users/:user_id/
func (h *ResidenceHandler) RemoveResidenceUser(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) RemoveResidenceUser(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
userIDToRemove, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")})
return
return apperrors.BadRequest("error.invalid_user_id")
}
err = h.residenceService.RemoveUser(uint(residenceID), uint(userIDToRemove), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.not_residence_owner")})
case errors.Is(err, services.ErrCannotRemoveOwner):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.cannot_remove_owner")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.user_removed")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": i18n.LocalizedMessage(c, "message.user_removed")})
}
// GetResidenceTypes handles GET /api/residences/types/
func (h *ResidenceHandler) GetResidenceTypes(c *gin.Context) {
func (h *ResidenceHandler) GetResidenceTypes(c echo.Context) error {
types, err := h.residenceService.GetResidenceTypes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, types)
return c.JSON(http.StatusOK, types)
}
// GenerateTasksReport handles POST /api/residences/:id/generate-tasks-report/
// Generates a PDF report of tasks for the residence and emails it
func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *ResidenceHandler) GenerateTasksReport(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
// Optional request body for email recipient
var req struct {
Email string `json:"email"`
}
c.ShouldBindJSON(&req)
c.Bind(&req)
// Generate the report data
report, err := h.residenceService.GenerateTasksReport(uint(residenceID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_not_found")})
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
// Determine recipient email
@@ -415,7 +325,7 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) {
message = i18n.LocalizedMessage(c, "message.tasks_report_email_failed")
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"message": message,
"residence_name": report.ResidenceName,
"recipient_email": recipientEmail,

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -19,22 +19,22 @@ import (
"gorm.io/gorm"
)
func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *gin.Engine, *gorm.DB) {
func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
residenceRepo := repositories.NewResidenceRepository(db)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
handler := NewResidenceHandler(residenceService, nil, nil)
router := testutil.SetupTestRouter()
return handler, router, db
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestResidenceHandler_CreateResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
@@ -47,7 +47,7 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
PostalCode: "78701",
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -88,7 +88,7 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
IsPrimary: &isPrimary,
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -114,28 +114,28 @@ func TestResidenceHandler_CreateResidence(t *testing.T) {
// Missing name - this is required
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetResidence)
otherAuthGroup := router.Group("/api/other-residences")
otherAuthGroup := e.Group("/api/other-residences")
otherAuthGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherAuthGroup.GET("/:id/", handler.GetResidence)
t.Run("get own residence", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -148,37 +148,37 @@ func TestResidenceHandler_GetResidence(t *testing.T) {
})
t.Run("get residence with invalid ID", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/invalid/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/invalid/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("get non-existent residence", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/9999/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/9999/", nil, "test-token")
// Returns 403 (access denied) rather than 404 to not reveal whether an ID exists
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_ListResidences(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
testutil.CreateTestResidence(t, db, user.ID, "House 1")
testutil.CreateTestResidence(t, db, user.ID, "House 2")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListResidences)
t.Run("list residences", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -191,7 +191,7 @@ func TestResidenceHandler_ListResidences(t *testing.T) {
}
func TestResidenceHandler_UpdateResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name")
@@ -200,11 +200,11 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateResidence)
sharedGroup := router.Group("/api/shared-residences")
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.PUT("/:id/", handler.UpdateResidence)
@@ -216,7 +216,7 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
City: &newCity,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -239,14 +239,14 @@ func TestResidenceHandler_UpdateResidence(t *testing.T) {
Name: &newName,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestResidenceHandler_DeleteResidence(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "To Delete")
@@ -254,22 +254,22 @@ func TestResidenceHandler_DeleteResidence(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteResidence)
sharedGroup := router.Group("/api/shared-residences")
sharedGroup := e.Group("/api/shared-residences")
sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser))
sharedGroup.DELETE("/:id/", handler.DeleteResidence)
t.Run("shared user cannot delete", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
t.Run("owner can delete", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -285,11 +285,11 @@ func TestResidenceHandler_DeleteResidence(t *testing.T) {
}
func TestResidenceHandler_GenerateShareCode(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Share Test")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode)
@@ -298,7 +298,7 @@ func TestResidenceHandler_GenerateShareCode(t *testing.T) {
ExpiresInHours: 24,
}
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -314,7 +314,7 @@ func TestResidenceHandler_GenerateShareCode(t *testing.T) {
}
func TestResidenceHandler_JoinWithCode(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Join Test")
@@ -326,11 +326,11 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
shareResp, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(newUser))
authGroup.POST("/join-with-code/", handler.JoinWithCode)
ownerGroup := router.Group("/api/owner-residences")
ownerGroup := e.Group("/api/owner-residences")
ownerGroup.Use(testutil.MockAuthMiddleware(owner))
ownerGroup.POST("/join-with-code/", handler.JoinWithCode)
@@ -339,7 +339,7 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: shareResp.ShareCode.Code,
}
w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -363,7 +363,7 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: shareResp2.ShareCode.Code,
}
w := testutil.MakeRequest(router, "POST", "/api/owner-residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/owner-residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusConflict)
})
@@ -373,14 +373,14 @@ func TestResidenceHandler_JoinWithCode(t *testing.T) {
Code: "ABCDEF", // Valid length (6) but non-existent code
}
w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/join-with-code/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
}
func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Users Test")
@@ -388,12 +388,12 @@ func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.GET("/:id/users/", handler.GetResidenceUsers)
t.Run("get residence users", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -406,7 +406,7 @@ func TestResidenceHandler_GetResidenceUsers(t *testing.T) {
}
func TestResidenceHandler_RemoveUser(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password")
residence := testutil.CreateTestResidence(t, db, owner.ID, "Remove Test")
@@ -414,12 +414,12 @@ func TestResidenceHandler_RemoveUser(t *testing.T) {
residenceRepo := repositories.NewResidenceRepository(db)
residenceRepo.AddUser(residence.ID, sharedUser.ID)
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(owner))
authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser)
t.Run("remove shared user", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -428,23 +428,23 @@ func TestResidenceHandler_RemoveUser(t *testing.T) {
})
t.Run("cannot remove owner", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestResidenceHandler_GetResidenceTypes(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/types/", handler.GetResidenceTypes)
t.Run("get residence types", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/types/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/types/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -457,10 +457,10 @@ func TestResidenceHandler_GetResidenceTypes(t *testing.T) {
}
func TestResidenceHandler_JSONResponses(t *testing.T) {
handler, router, db := setupResidenceHandler(t)
handler, e, db := setupResidenceHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
authGroup := router.Group("/api/residences")
authGroup := e.Group("/api/residences")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateResidence)
authGroup.GET("/", handler.ListResidences)
@@ -474,7 +474,7 @@ func TestResidenceHandler_JSONResponses(t *testing.T) {
PostalCode: "78701",
}
w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/residences/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -513,7 +513,7 @@ func TestResidenceHandler_JSONResponses(t *testing.T) {
})
t.Run("list response returns array", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/residences/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
@@ -51,20 +51,19 @@ func NewStaticDataHandler(
// GetStaticData handles GET /api/static_data/
// Returns all lookup/reference data in a single response with ETag support
func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
ctx := c.Request.Context()
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context()
// Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
clientETag := strings.TrimPrefix(c.GetHeader("If-None-Match"), "W/")
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx)
if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified
c.Status(http.StatusNotModified)
return
return c.NoContent(http.StatusNotModified)
}
}
@@ -76,11 +75,10 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
// Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx)
if etagErr == nil {
c.Header("ETag", etag)
c.Header("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
c.JSON(http.StatusOK, cachedData)
return
return c.JSON(http.StatusOK, cachedData)
} else if err != redis.Nil {
// Log cache error but continue to fetch from DB
log.Warn().Err(err).Msg("Failed to get cached seeded data")
@@ -90,38 +88,32 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
// Cache miss - fetch all data from services
residenceTypes, err := h.residenceService.GetResidenceTypes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_residence_types")})
return
return err
}
taskCategories, err := h.taskService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_categories")})
return
return err
}
taskPriorities, err := h.taskService.GetPriorities()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_priorities")})
return
return err
}
taskFrequencies, err := h.taskService.GetFrequencies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_frequencies")})
return
return err
}
contractorSpecialties, err := h.contractorService.GetSpecialties()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_contractor_specialties")})
return
return err
}
taskTemplates, err := h.taskTemplateService.GetGrouped()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_task_templates")})
return
return err
}
// Build response
@@ -140,19 +132,19 @@ func (h *StaticDataHandler) GetStaticData(c *gin.Context) {
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else {
c.Header("ETag", etag)
c.Header("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
}
}
c.JSON(http.StatusOK, seededData)
return c.JSON(http.StatusOK, seededData)
}
// RefreshStaticData handles POST /api/static_data/refresh/
// This is a no-op since data is fetched fresh each time
// Kept for API compatibility with mobile clients
func (h *StaticDataHandler) RefreshStaticData(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
func (h *StaticDataHandler) RefreshStaticData(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": i18n.LocalizedMessage(c, "message.static_data_refreshed"),
"status": "success",
})

View File

@@ -1,12 +1,11 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -23,91 +22,80 @@ func NewSubscriptionHandler(subscriptionService *services.SubscriptionService) *
}
// GetSubscription handles GET /api/subscription/
func (h *SubscriptionHandler) GetSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
subscription, err := h.subscriptionService.GetSubscription(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, subscription)
return c.JSON(http.StatusOK, subscription)
}
// GetSubscriptionStatus handles GET /api/subscription/status/
func (h *SubscriptionHandler) GetSubscriptionStatus(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetSubscriptionStatus(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
status, err := h.subscriptionService.GetSubscriptionStatus(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, status)
return c.JSON(http.StatusOK, status)
}
// GetUpgradeTrigger handles GET /api/subscription/upgrade-trigger/:key/
func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
func (h *SubscriptionHandler) GetUpgradeTrigger(c echo.Context) error {
key := c.Param("key")
trigger, err := h.subscriptionService.GetUpgradeTrigger(key)
if err != nil {
if errors.Is(err, services.ErrUpgradeTriggerNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.upgrade_trigger_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, trigger)
return c.JSON(http.StatusOK, trigger)
}
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c *gin.Context) {
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c echo.Context) error {
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, triggers)
return c.JSON(http.StatusOK, triggers)
}
// GetFeatureBenefits handles GET /api/subscription/features/
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
func (h *SubscriptionHandler) GetFeatureBenefits(c echo.Context) error {
benefits, err := h.subscriptionService.GetFeatureBenefits()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, benefits)
return c.JSON(http.StatusOK, benefits)
}
// GetPromotions handles GET /api/subscription/promotions/
func (h *SubscriptionHandler) GetPromotions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) GetPromotions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
promotions, err := h.subscriptionService.GetActivePromotions(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, promotions)
return c.JSON(http.StatusOK, promotions)
}
// ProcessPurchase handles POST /api/subscription/purchase/
func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.ProcessPurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
var subscription *services.SubscriptionResponse
@@ -117,53 +105,48 @@ func (h *SubscriptionHandler) ProcessPurchase(c *gin.Context) {
case "ios":
// StoreKit 2 uses transaction_id, StoreKit 1 uses receipt_data
if req.TransactionID == "" && req.ReceiptData == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.receipt_data_required")})
return
return apperrors.BadRequest("error.receipt_data_required")
}
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
case "android":
if req.PurchaseToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.purchase_token_required")})
return
return apperrors.BadRequest("error.purchase_token_required")
}
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_upgraded"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_upgraded",
"subscription": subscription,
})
}
// CancelSubscription handles POST /api/subscription/cancel/
func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) CancelSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
subscription, err := h.subscriptionService.CancelSubscription(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_cancelled"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_cancelled",
"subscription": subscription,
})
}
// RestoreSubscription handles POST /api/subscription/restore/
func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req services.ProcessPurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
// Same logic as ProcessPurchase - validates receipt/token and restores
@@ -178,12 +161,11 @@ func (h *SubscriptionHandler) RestoreSubscription(c *gin.Context) {
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.LocalizedMessage(c, "message.subscription_restored"),
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "message.subscription_restored",
"subscription": subscription,
})
}

View File

@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/treytartt/casera-api/internal/config"
@@ -93,27 +93,24 @@ type AppleRenewalInfo struct {
}
// HandleAppleWebhook handles POST /api/subscription/webhook/apple/
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Apple Webhook: Failed to read body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var payload AppleNotificationPayload
if err := json.Unmarshal(body, &payload); err != nil {
log.Printf("Apple Webhook: Failed to parse payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid payload"})
}
// Decode and verify the signed payload (JWS)
notification, err := h.decodeAppleSignedPayload(payload.SignedPayload)
if err != nil {
log.Printf("Apple Webhook: Failed to decode signed payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signed payload"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid signed payload"})
}
log.Printf("Apple Webhook: Received %s (subtype: %s) for bundle %s",
@@ -125,8 +122,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
if notification.Data.BundleID != cfg.AppleIAP.BundleID {
log.Printf("Apple Webhook: Bundle ID mismatch: got %s, expected %s",
notification.Data.BundleID, cfg.AppleIAP.BundleID)
c.JSON(http.StatusBadRequest, gin.H{"error": "bundle ID mismatch"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "bundle ID mismatch"})
}
}
@@ -134,8 +130,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
transactionInfo, err := h.decodeAppleTransaction(notification.Data.SignedTransactionInfo)
if err != nil {
log.Printf("Apple Webhook: Failed to decode transaction: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid transaction info"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid transaction info"})
}
// Decode renewal info if present
@@ -151,7 +146,7 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c *gin.Context) {
}
// Always return 200 OK to acknowledge receipt
c.JSON(http.StatusOK, gin.H{"status": "received"})
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// decodeAppleSignedPayload decodes and verifies an Apple JWS payload
@@ -454,41 +449,36 @@ const (
)
// HandleGoogleWebhook handles POST /api/subscription/webhook/google/
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
log.Printf("Google Webhook: Failed to read body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "failed to read request body"})
}
var notification GoogleNotification
if err := json.Unmarshal(body, &notification); err != nil {
log.Printf("Google Webhook: Failed to parse notification: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid notification"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid notification"})
}
// Decode the base64 data
data, err := base64.StdEncoding.DecodeString(notification.Message.Data)
if err != nil {
log.Printf("Google Webhook: Failed to decode message data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message data"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid message data"})
}
var devNotification GoogleDeveloperNotification
if err := json.Unmarshal(data, &devNotification); err != nil {
log.Printf("Google Webhook: Failed to parse developer notification: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid developer notification"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "invalid developer notification"})
}
// Handle test notification
if devNotification.TestNotification != nil {
log.Printf("Google Webhook: Received test notification")
c.JSON(http.StatusOK, gin.H{"status": "test received"})
return
return c.JSON(http.StatusOK, map[string]interface{}{"status": "test received"})
}
// Verify package name
@@ -497,8 +487,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
if devNotification.PackageName != cfg.GoogleIAP.PackageName {
log.Printf("Google Webhook: Package name mismatch: got %s, expected %s",
devNotification.PackageName, cfg.GoogleIAP.PackageName)
c.JSON(http.StatusBadRequest, gin.H{"error": "package name mismatch"})
return
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "package name mismatch"})
}
}
@@ -511,7 +500,7 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c *gin.Context) {
}
// Acknowledge the message
c.JSON(http.StatusOK, gin.H{"status": "received"})
return c.JSON(http.StatusOK, map[string]interface{}{"status": "received"})
}
// processGoogleSubscriptionNotification handles Google subscription events
@@ -736,7 +725,7 @@ func (h *SubscriptionWebhookHandler) VerifyAppleSignature(signedPayload string)
}
// VerifyGooglePubSubToken verifies the Pub/Sub push token (if configured)
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c *gin.Context) bool {
func (h *SubscriptionWebhookHandler) VerifyGooglePubSubToken(c echo.Context) bool {
// If you configured a push endpoint with authentication, verify here
// The token is typically in the Authorization header

View File

@@ -1,18 +1,17 @@
package handlers
import (
"errors"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -33,55 +32,44 @@ func NewTaskHandler(taskService *services.TaskService, storageService *services.
}
// ListTasks handles GET /api/tasks/
func (h *TaskHandler) ListTasks(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ListTasks(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
response, err := h.taskService.ListTasks(user.ID, userNow)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetTask handles GET /api/tasks/:id/
func (h *TaskHandler) GetTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.GetTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetTasksByResidence handles GET /api/tasks/by-residence/:residence_id/
func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTasksByResidence(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
residenceID, err := strconv.ParseUint(c.Param("residence_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_residence_id")})
return
return apperrors.BadRequest("error.invalid_residence_id")
}
daysThreshold := 30
if d := c.Query("days_threshold"); d != "" {
if d := c.QueryParam("days_threshold"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil {
daysThreshold = parsed
}
@@ -89,352 +77,241 @@ func (h *TaskHandler) GetTasksByResidence(c *gin.Context) {
response, err := h.taskService.GetTasksByResidence(uint(residenceID), user.ID, daysThreshold, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateTask handles POST /api/tasks/
func (h *TaskHandler) CreateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CreateTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.taskService.CreateTask(&req, user.ID, userNow)
if err != nil {
if errors.Is(err, services.ErrResidenceAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.residence_access_denied")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// UpdateTask handles PUT/PATCH /api/tasks/:id/
func (h *TaskHandler) UpdateTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UpdateTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
var req requests.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
response, err := h.taskService.UpdateTask(uint(taskID), user.ID, &req, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// DeleteTask handles DELETE /api/tasks/:id/
func (h *TaskHandler) DeleteTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) DeleteTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.DeleteTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// MarkInProgress handles POST /api/tasks/:id/mark-in-progress/
func (h *TaskHandler) MarkInProgress(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) MarkInProgress(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.MarkInProgress(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CancelTask handles POST /api/tasks/:id/cancel/
func (h *TaskHandler) CancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CancelTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.CancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyCancelled):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_cancelled")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UncancelTask handles POST /api/tasks/:id/uncancel/
func (h *TaskHandler) UncancelTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UncancelTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.UncancelTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ArchiveTask handles POST /api/tasks/:id/archive/
func (h *TaskHandler) ArchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ArchiveTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.ArchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
case errors.Is(err, services.ErrTaskAlreadyArchived):
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_already_archived")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// UnarchiveTask handles POST /api/tasks/:id/unarchive/
func (h *TaskHandler) UnarchiveTask(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) UnarchiveTask(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.UnarchiveTask(uint(taskID), user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// QuickComplete handles POST /api/tasks/:id/quick-complete/
// Lightweight endpoint for widget - just returns 200 OK on success
func (h *TaskHandler) QuickComplete(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) QuickComplete(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
err = h.taskService.QuickComplete(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.Status(http.StatusOK)
return c.NoContent(http.StatusOK)
}
// === Task Completions ===
// GetTaskCompletions handles GET /api/tasks/:id/completions/
func (h *TaskHandler) GetTaskCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetTaskCompletions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
taskID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id")})
return
return apperrors.BadRequest("error.invalid_task_id")
}
response, err := h.taskService.GetCompletionsByTask(uint(taskID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// ListCompletions handles GET /api/task-completions/
func (h *TaskHandler) ListCompletions(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) ListCompletions(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.taskService.ListCompletions(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// GetCompletion handles GET /api/task-completions/:id/
func (h *TaskHandler) GetCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) GetCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
return apperrors.BadRequest("error.invalid_completion_id")
}
response, err := h.taskService.GetCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// CreateCompletion handles POST /api/task-completions/
// Supports both JSON and multipart form data (for image uploads)
func (h *TaskHandler) CreateCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) CreateCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskCompletionRequest
contentType := c.GetHeader("Content-Type")
contentType := c.Request().Header.Get("Content-Type")
// Check if this is a multipart form request (image upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_parse_form")})
return
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse task_id (required)
taskIDStr := c.PostForm("task_id")
taskIDStr := c.FormValue("task_id")
if taskIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.task_id_required")})
return
return apperrors.BadRequest("error.task_id_required")
}
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_task_id_value")})
return
return apperrors.BadRequest("error.invalid_task_id_value")
}
req.TaskID = uint(taskID)
// Parse notes (optional)
req.Notes = c.PostForm("notes")
req.Notes = c.FormValue("notes")
// Parse actual_cost (optional)
if costStr := c.PostForm("actual_cost"); costStr != "" {
if costStr := c.FormValue("actual_cost"); costStr != "" {
cost, err := decimal.NewFromString(costStr)
if err == nil {
req.ActualCost = &cost
@@ -442,7 +319,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
}
// Parse completed_at (optional)
if completedAtStr := c.PostForm("completed_at"); completedAtStr != "" {
if completedAtStr := c.FormValue("completed_at"); completedAtStr != "" {
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
req.CompletedAt = &t
}
@@ -462,87 +339,65 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
if h.storageService != nil {
result, err := h.storageService.Upload(file, "completions")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_upload_image")})
return
return apperrors.BadRequest("error.failed_to_upload_image")
}
req.ImageURLs = append(req.ImageURLs, result.URL)
}
}
} else {
// Standard JSON request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
}
response, err := h.taskService.CreateCompletion(&req, user.ID, userNow)
if err != nil {
switch {
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.task_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusCreated, response)
return c.JSON(http.StatusCreated, response)
}
// DeleteCompletion handles DELETE /api/task-completions/:id/
func (h *TaskHandler) DeleteCompletion(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *TaskHandler) DeleteCompletion(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_completion_id")})
return
return apperrors.BadRequest("error.invalid_completion_id")
}
response, err := h.taskService.DeleteCompletion(uint(completionID), user.ID)
if err != nil {
switch {
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.completion_not_found")})
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, gin.H{"error": i18n.LocalizedMessage(c, "error.task_access_denied")})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
return err
}
c.JSON(http.StatusOK, response)
return c.JSON(http.StatusOK, response)
}
// === Lookups ===
// GetCategories handles GET /api/tasks/categories/
func (h *TaskHandler) GetCategories(c *gin.Context) {
func (h *TaskHandler) GetCategories(c echo.Context) error {
categories, err := h.taskService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, categories)
return c.JSON(http.StatusOK, categories)
}
// GetPriorities handles GET /api/tasks/priorities/
func (h *TaskHandler) GetPriorities(c *gin.Context) {
func (h *TaskHandler) GetPriorities(c echo.Context) error {
priorities, err := h.taskService.GetPriorities()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, priorities)
return c.JSON(http.StatusOK, priorities)
}
// GetFrequencies handles GET /api/tasks/frequencies/
func (h *TaskHandler) GetFrequencies(c *gin.Context) {
func (h *TaskHandler) GetFrequencies(c echo.Context) error {
frequencies, err := h.taskService.GetFrequencies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, frequencies)
return c.JSON(http.StatusOK, frequencies)
}

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -20,23 +20,23 @@ import (
"gorm.io/gorm"
)
func setupTaskHandler(t *testing.T) (*TaskHandler, *gin.Engine, *gorm.DB) {
func setupTaskHandler(t *testing.T) (*TaskHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskService := services.NewTaskService(taskRepo, residenceRepo)
handler := NewTaskHandler(taskService, nil)
router := testutil.SetupTestRouter()
return handler, router, db
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestTaskHandler_CreateTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
@@ -47,7 +47,7 @@ func TestTaskHandler_CreateTask(t *testing.T) {
Description: "Kitchen faucet is dripping",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -86,7 +86,7 @@ func TestTaskHandler_CreateTask(t *testing.T) {
EstimatedCost: &estimatedCost,
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -116,29 +116,29 @@ func TestTaskHandler_CreateTask(t *testing.T) {
Title: "Unauthorized Task",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_GetTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetTask)
otherGroup := router.Group("/api/other-tasks")
otherGroup := e.Group("/api/other-tasks")
otherGroup.Use(testutil.MockAuthMiddleware(otherUser))
otherGroup.GET("/:id/", handler.GetTask)
t.Run("get own task", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -151,32 +151,32 @@ func TestTaskHandler_GetTask(t *testing.T) {
})
t.Run("get non-existent task", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/9999/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/9999/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusNotFound)
})
t.Run("access denied for other user", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusForbidden)
})
}
func TestTaskHandler_ListTasks(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2")
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListTasks)
t.Run("list tasks", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -201,7 +201,7 @@ func TestTaskHandler_ListTasks(t *testing.T) {
}
func TestTaskHandler_GetTasksByResidence(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
@@ -209,12 +209,12 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
// Create tasks with different states
testutil.CreateTestTask(t, db, residence.ID, user.ID, "Active Task")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/by-residence/:residence_id/", handler.GetTasksByResidence)
t.Run("get kanban columns", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -231,7 +231,7 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
})
t.Run("kanban column structure", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -254,12 +254,12 @@ func TestTaskHandler_GetTasksByResidence(t *testing.T) {
}
func TestTaskHandler_UpdateTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/:id/", handler.UpdateTask)
@@ -271,7 +271,7 @@ func TestTaskHandler_UpdateTask(t *testing.T) {
Description: &newDesc,
}
w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token")
w := testutil.MakeRequest(e, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -290,17 +290,17 @@ func TestTaskHandler_UpdateTask(t *testing.T) {
}
func TestTaskHandler_DeleteTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Delete")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteTask)
t.Run("delete task", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -316,17 +316,17 @@ func TestTaskHandler_DeleteTask(t *testing.T) {
}
func TestTaskHandler_CancelTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Cancel")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/cancel/", handler.CancelTask)
t.Run("cancel task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -344,14 +344,14 @@ func TestTaskHandler_CancelTask(t *testing.T) {
t.Run("cancel already cancelled task", func(t *testing.T) {
// Already cancelled from previous test
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestTaskHandler_UncancelTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Uncancel")
@@ -360,12 +360,12 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Cancel(task.ID)
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/uncancel/", handler.UncancelTask)
t.Run("uncancel task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -383,17 +383,17 @@ func TestTaskHandler_UncancelTask(t *testing.T) {
}
func TestTaskHandler_ArchiveTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Archive")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/archive/", handler.ArchiveTask)
t.Run("archive task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -411,7 +411,7 @@ func TestTaskHandler_ArchiveTask(t *testing.T) {
}
func TestTaskHandler_UnarchiveTask(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Unarchive")
@@ -420,12 +420,12 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
taskRepo := repositories.NewTaskRepository(db)
taskRepo.Archive(task.ID)
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/unarchive/", handler.UnarchiveTask)
t.Run("unarchive task", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -443,18 +443,18 @@ func TestTaskHandler_UnarchiveTask(t *testing.T) {
}
func TestTaskHandler_MarkInProgress(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Start")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/:id/mark-in-progress/", handler.MarkInProgress)
t.Run("mark in progress", func(t *testing.T) {
w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token")
w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -470,12 +470,12 @@ func TestTaskHandler_MarkInProgress(t *testing.T) {
}
func TestTaskHandler_CreateCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Complete")
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateCompletion)
@@ -487,7 +487,7 @@ func TestTaskHandler_CreateCompletion(t *testing.T) {
Notes: "Completed successfully",
}
w := testutil.MakeRequest(router, "POST", "/api/task-completions/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/task-completions/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -507,7 +507,7 @@ func TestTaskHandler_CreateCompletion(t *testing.T) {
}
func TestTaskHandler_ListCompletions(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -521,12 +521,12 @@ func TestTaskHandler_ListCompletions(t *testing.T) {
})
}
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/", handler.ListCompletions)
t.Run("list completions", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/task-completions/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/task-completions/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -539,7 +539,7 @@ func TestTaskHandler_ListCompletions(t *testing.T) {
}
func TestTaskHandler_GetCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -552,12 +552,12 @@ func TestTaskHandler_GetCompletion(t *testing.T) {
}
db.Create(completion)
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/:id/", handler.GetCompletion)
t.Run("get completion", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -571,7 +571,7 @@ func TestTaskHandler_GetCompletion(t *testing.T) {
}
func TestTaskHandler_DeleteCompletion(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
@@ -583,12 +583,12 @@ func TestTaskHandler_DeleteCompletion(t *testing.T) {
}
db.Create(completion)
authGroup := router.Group("/api/task-completions")
authGroup := e.Group("/api/task-completions")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/:id/", handler.DeleteCompletion)
t.Run("delete completion", func(t *testing.T) {
w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -604,18 +604,18 @@ func TestTaskHandler_DeleteCompletion(t *testing.T) {
}
func TestTaskHandler_GetLookups(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/categories/", handler.GetCategories)
authGroup.GET("/priorities/", handler.GetPriorities)
authGroup.GET("/frequencies/", handler.GetFrequencies)
t.Run("get categories", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/categories/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/categories/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -629,7 +629,7 @@ func TestTaskHandler_GetLookups(t *testing.T) {
})
t.Run("get priorities", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/priorities/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/priorities/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -644,7 +644,7 @@ func TestTaskHandler_GetLookups(t *testing.T) {
})
t.Run("get frequencies", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/frequencies/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/frequencies/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
@@ -657,12 +657,12 @@ func TestTaskHandler_GetLookups(t *testing.T) {
}
func TestTaskHandler_JSONResponses(t *testing.T) {
handler, router, db := setupTaskHandler(t)
handler, e, db := setupTaskHandler(t)
testutil.SeedLookupData(t, db)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
authGroup := router.Group("/api/tasks")
authGroup := e.Group("/api/tasks")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/", handler.CreateTask)
authGroup.GET("/", handler.ListTasks)
@@ -674,7 +674,7 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
Description: "Testing JSON structure",
}
w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token")
w := testutil.MakeRequest(e, "POST", "/api/tasks/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusCreated)
@@ -714,7 +714,7 @@ func TestTaskHandler_JSONResponses(t *testing.T) {
})
t.Run("list response returns kanban board", func(t *testing.T) {
w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token")
w := testutil.MakeRequest(e, "GET", "/api/tasks/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/services"
)
@@ -24,83 +24,74 @@ func NewTaskTemplateHandler(templateService *services.TaskTemplateService) *Task
// GetTemplates handles GET /api/tasks/templates/
// Returns all active task templates as a flat list
func (h *TaskTemplateHandler) GetTemplates(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplates(c echo.Context) error {
templates, err := h.templateService.GetAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplatesGrouped handles GET /api/tasks/templates/grouped/
// Returns all templates grouped by category
func (h *TaskTemplateHandler) GetTemplatesGrouped(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplatesGrouped(c echo.Context) error {
grouped, err := h.templateService.GetGrouped()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, grouped)
return c.JSON(http.StatusOK, grouped)
}
// SearchTemplates handles GET /api/tasks/templates/search/
// Searches templates by query string
func (h *TaskTemplateHandler) SearchTemplates(c *gin.Context) {
query := c.Query("q")
func (h *TaskTemplateHandler) SearchTemplates(c echo.Context) error {
query := c.QueryParam("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
return
return apperrors.BadRequest("error.query_required")
}
if len(query) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query must be at least 2 characters"})
return
return apperrors.BadRequest("error.query_too_short")
}
templates, err := h.templateService.Search(query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_search_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplatesByCategory handles GET /api/tasks/templates/by-category/:category_id/
// Returns templates for a specific category
func (h *TaskTemplateHandler) GetTemplatesByCategory(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplatesByCategory(c echo.Context) error {
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
templates, err := h.templateService.GetByCategory(uint(categoryID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
return
return err
}
c.JSON(http.StatusOK, templates)
return c.JSON(http.StatusOK, templates)
}
// GetTemplate handles GET /api/tasks/templates/:id/
// Returns a single template by ID
func (h *TaskTemplateHandler) GetTemplate(c *gin.Context) {
func (h *TaskTemplateHandler) GetTemplate(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
return apperrors.BadRequest("error.invalid_id")
}
template, err := h.templateService.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.template_not_found")})
return
return err
}
c.JSON(http.StatusOK, template)
return c.JSON(http.StatusOK, template)
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/base64"
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/services"
)
@@ -26,7 +26,7 @@ var transparentGIF, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP/
// TrackEmailOpen handles email open tracking via tracking pixel
// GET /api/track/open/:trackingID
func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) {
func (h *TrackingHandler) TrackEmailOpen(c echo.Context) error {
trackingID := c.Param("trackingID")
if trackingID != "" && h.onboardingService != nil {
@@ -37,9 +37,9 @@ func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) {
}
// Return 1x1 transparent GIF
c.Header("Content-Type", "image/gif")
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Data(http.StatusOK, "image/gif", transparentGIF)
c.Response().Header().Set("Content-Type", "image/gif")
c.Response().Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return c.Blob(http.StatusOK, "image/gif", transparentGIF)
}

View File

@@ -3,9 +3,9 @@ package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/services"
)
@@ -21,77 +21,72 @@ func NewUploadHandler(storageService *services.StorageService) *UploadHandler {
// UploadImage handles POST /api/uploads/image
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadImage(c *gin.Context) {
func (h *UploadHandler) UploadImage(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
// Get category from query param (default: images)
category := c.DefaultQuery("category", "images")
category := c.QueryParam("category")
if category == "" {
category = "images"
}
result, err := h.storageService.Upload(file, category)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// UploadDocument handles POST /api/uploads/document
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadDocument(c *gin.Context) {
func (h *UploadHandler) UploadDocument(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(file, "documents")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// UploadCompletion handles POST /api/uploads/completion
// For task completion photos
func (h *UploadHandler) UploadCompletion(c *gin.Context) {
func (h *UploadHandler) UploadCompletion(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.no_file_provided")})
return
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(file, "completions")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, result)
return c.JSON(http.StatusOK, result)
}
// DeleteFile handles DELETE /api/uploads
// Expects JSON body with "url" field
func (h *UploadHandler) DeleteFile(c *gin.Context) {
func (h *UploadHandler) DeleteFile(c echo.Context) error {
var req struct {
URL string `json:"url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := h.storageService.Delete(req.URL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{"message": i18n.LocalizedMessage(c, "message.file_deleted")})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
}

View File

@@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
@@ -25,58 +25,50 @@ func NewUserHandler(userService *services.UserService) *UserHandler {
}
// ListUsers handles GET /api/users/
func (h *UserHandler) ListUsers(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) ListUsers(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
// Only allow listing users that share residences with the current user
users, err := h.userService.ListUsersInSharedResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(users),
"results": users,
})
}
// GetUser handles GET /api/users/:id/
func (h *UserHandler) GetUser(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) GetUser(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
userID, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.LocalizedMessage(c, "error.invalid_user_id")})
return
return apperrors.BadRequest("error.invalid_user_id")
}
// Can only view users that share a residence
targetUser, err := h.userService.GetUserIfSharedResidence(uint(userID), user.ID)
if err != nil {
if err == services.ErrUserNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.user_not_found")})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, targetUser)
return c.JSON(http.StatusOK, targetUser)
}
// ListProfiles handles GET /api/users/profiles/
func (h *UserHandler) ListProfiles(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
func (h *UserHandler) ListProfiles(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
// List profiles of users in shared residences
profiles, err := h.userService.ListProfilesInSharedResidences(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
return err
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"count": len(profiles),
"results": profiles,
})

View File

@@ -3,39 +3,41 @@ package i18n
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
const (
// LocalizerKey is the key used to store the localizer in Gin context
// LocalizerKey is the key used to store the localizer in Echo context
LocalizerKey = "i18n_localizer"
// LocaleKey is the key used to store the detected locale in Gin context
// LocaleKey is the key used to store the detected locale in Echo context
LocaleKey = "i18n_locale"
)
// Middleware returns a Gin middleware that detects the user's preferred language
// Middleware returns an Echo middleware that detects the user's preferred language
// from the Accept-Language header and stores a localizer in the context
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Get Accept-Language header
acceptLang := c.GetHeader("Accept-Language")
func Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Get Accept-Language header
acceptLang := c.Request().Header.Get("Accept-Language")
// Parse the preferred languages
langs := parseAcceptLanguage(acceptLang)
// Parse the preferred languages
langs := parseAcceptLanguage(acceptLang)
// Create localizer with the preferred languages
localizer := NewLocalizer(langs...)
// Create localizer with the preferred languages
localizer := NewLocalizer(langs...)
// Determine the best matched locale for storage
locale := matchLocale(langs)
// Determine the best matched locale for storage
locale := matchLocale(langs)
// Store in context
c.Set(LocalizerKey, localizer)
c.Set(LocaleKey, locale)
// Store in context
c.Set(LocalizerKey, localizer)
c.Set(LocaleKey, locale)
c.Next()
return next(c)
}
}
}
@@ -86,9 +88,10 @@ func matchLocale(langs []string) string {
return DefaultLanguage
}
// GetLocalizer retrieves the localizer from the Gin context
func GetLocalizer(c *gin.Context) *i18n.Localizer {
if localizer, exists := c.Get(LocalizerKey); exists {
// GetLocalizer retrieves the localizer from the Echo context
func GetLocalizer(c echo.Context) *i18n.Localizer {
localizer := c.Get(LocalizerKey)
if localizer != nil {
if l, ok := localizer.(*i18n.Localizer); ok {
return l
}
@@ -96,9 +99,10 @@ func GetLocalizer(c *gin.Context) *i18n.Localizer {
return NewLocalizer(DefaultLanguage)
}
// GetLocale retrieves the detected locale from the Gin context
func GetLocale(c *gin.Context) string {
if locale, exists := c.Get(LocaleKey); exists {
// GetLocale retrieves the detected locale from the Echo context
func GetLocale(c echo.Context) string {
locale := c.Get(LocaleKey)
if locale != nil {
if l, ok := locale.(string); ok {
return l
}
@@ -107,16 +111,16 @@ func GetLocale(c *gin.Context) string {
}
// LocalizedError returns a localized error message
func LocalizedError(c *gin.Context, messageID string, templateData map[string]interface{}) string {
func LocalizedError(c echo.Context, messageID string, templateData map[string]interface{}) string {
return T(GetLocalizer(c), messageID, templateData)
}
// LocalizedMessage returns a localized message
func LocalizedMessage(c *gin.Context, messageID string) string {
func LocalizedMessage(c echo.Context, messageID string) string {
return TSimple(GetLocalizer(c), messageID)
}
// LocalizedMessageWithData returns a localized message with template data
func LocalizedMessageWithData(c *gin.Context, messageID string, templateData map[string]interface{}) string {
func LocalizedMessageWithData(c echo.Context, messageID string, templateData map[string]interface{}) string {
return T(GetLocalizer(c), messageID, templateData)
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,11 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/handlers"
"github.com/treytartt/casera-api/internal/middleware"
@@ -19,19 +20,20 @@ import (
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
"github.com/treytartt/casera-api/internal/testutil"
"github.com/treytartt/casera-api/internal/validator"
"gorm.io/gorm"
)
// SubscriptionTestApp holds components for subscription integration testing
type SubscriptionTestApp struct {
DB *gorm.DB
Router *gin.Engine
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
}
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
gin.SetMode(gin.TestMode)
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -65,10 +67,12 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
// Create router
router := gin.New()
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Public routes
auth := router.Group("/api/auth")
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
@@ -76,7 +80,7 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := router.Group("/api")
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
{
api.GET("/auth/me", authHandler.CurrentUser)
@@ -95,7 +99,7 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
return &SubscriptionTestApp{
DB: db,
Router: router,
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
@@ -247,7 +251,10 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
// Second property should fail
err = app.SubscriptionService.CheckLimit(userID, "properties")
assert.Error(t, err, "Second property should be blocked for normal free user")
assert.Equal(t, services.ErrPropertiesLimitExceeded, err)
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusForbidden, appErr.Code)
assert.Equal(t, "error.properties_limit_exceeded", appErr.MessageKey)
// ========== Test 2: Set IsFree=true ==========
sub.IsFree = true

View File

@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/golang-jwt/jwt/v5"
"github.com/treytartt/casera-api/internal/config"
@@ -30,68 +30,65 @@ type AdminClaims struct {
}
// AdminAuthMiddleware creates a middleware that validates admin JWT tokens
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc {
return func(c *gin.Context) {
var tokenString string
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
var tokenString string
// Get token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
// Check Bearer prefix
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString = parts[1]
// Get token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
// Check Bearer prefix
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString = parts[1]
}
}
}
// If no header token, check query parameter (for WebSocket connections)
if tokenString == "" {
tokenString = c.Query("token")
}
if tokenString == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
// Parse and validate token
claims := &AdminClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
// If no header token, check query parameter (for WebSocket connections)
if tokenString == "" {
tokenString = c.QueryParam("token")
}
return []byte(cfg.Security.SecretKey), nil
})
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
if tokenString == "" {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Authorization required"})
}
// Parse and validate token
claims := &AdminClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return []byte(cfg.Security.SecretKey), nil
})
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Invalid token"})
}
if !token.Valid {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Token is not valid"})
}
// Get admin user from database
admin, err := adminRepo.FindByID(claims.AdminID)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Admin user not found"})
}
// Check if admin is active
if !admin.IsActive {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Admin account is disabled"})
}
// Store admin and claims in context
c.Set(AdminUserKey, admin)
c.Set(AdminClaimsKey, claims)
return next(c)
}
if !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token is not valid"})
return
}
// Get admin user from database
admin, err := adminRepo.FindByID(claims.AdminID)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin user not found"})
return
}
// Check if admin is active
if !admin.IsActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin account is disabled"})
return
}
// Store admin and claims in context
c.Set(AdminUserKey, admin)
c.Set(AdminClaimsKey, claims)
c.Next()
}
}
@@ -116,20 +113,20 @@ func GenerateAdminToken(admin *models.AdminUser, cfg *config.Config) (string, er
}
// RequireSuperAdmin middleware requires the admin to have super_admin role
func RequireSuperAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
admin, exists := c.Get(AdminUserKey)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin authentication required"})
return
}
func RequireSuperAdmin() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin := c.Get(AdminUserKey)
if admin == nil {
return c.JSON(http.StatusUnauthorized, map[string]interface{}{"error": "Admin authentication required"})
}
adminUser := admin.(*models.AdminUser)
if !adminUser.IsSuperAdmin() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Super admin privileges required"})
return
}
adminUser := admin.(*models.AdminUser)
if !adminUser.IsSuperAdmin() {
return c.JSON(http.StatusForbidden, map[string]interface{}{"error": "Super admin privileges required"})
}
c.Next()
return next(c)
}
}
}

View File

@@ -3,15 +3,15 @@ package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/services"
)
@@ -41,84 +41,79 @@ func NewAuthMiddleware(db *gorm.DB, cache *services.CacheService) *AuthMiddlewar
}
}
// TokenAuth returns a Gin middleware that validates token authentication
func (m *AuthMiddleware) TokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// Extract token from Authorization header
token, err := extractToken(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": err.Error(),
})
return
}
// TokenAuth returns an Echo middleware that validates token authentication
func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract token from Authorization header
token, err := extractToken(c)
if err != nil {
return apperrors.Unauthorized("error.not_authenticated")
}
// Try to get user from cache first
user, err := m.getUserFromCache(c.Request.Context(), token)
if err == nil && user != nil {
// Cache hit - set user in context and continue
// Try to get user from cache first
user, err := m.getUserFromCache(c.Request().Context(), token)
if err == nil && user != nil {
// Cache hit - set user in context and continue
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
return next(c)
}
// Cache miss - look up token in database
user, err = m.getUserFromDatabase(token)
if err != nil {
log.Debug().Err(err).Str("token", token[:8]+"...").Msg("Token authentication failed")
return apperrors.Unauthorized("error.invalid_token")
}
// Cache the user ID for future requests
if cacheErr := m.cacheUserID(c.Request().Context(), token, user.ID); cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache user ID")
}
// Set user in context
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
c.Next()
return
return next(c)
}
// Cache miss - look up token in database
user, err = m.getUserFromDatabase(token)
if err != nil {
log.Debug().Err(err).Str("token", token[:8]+"...").Msg("Token authentication failed")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid token",
})
return
}
// Cache the user ID for future requests
if cacheErr := m.cacheUserID(c.Request.Context(), token, user.ID); cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache user ID")
}
// Set user in context
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
c.Next()
}
}
// OptionalTokenAuth returns middleware that authenticates if token is present but doesn't require it
func (m *AuthMiddleware) OptionalTokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := extractToken(c)
if err != nil {
// No token or invalid format - continue without user
c.Next()
return
}
func (m *AuthMiddleware) OptionalTokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token, err := extractToken(c)
if err != nil {
// No token or invalid format - continue without user
return next(c)
}
// Try cache first
user, err := m.getUserFromCache(c.Request.Context(), token)
if err == nil && user != nil {
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
c.Next()
return
}
// Try cache first
user, err := m.getUserFromCache(c.Request().Context(), token)
if err == nil && user != nil {
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
return next(c)
}
// Try database
user, err = m.getUserFromDatabase(token)
if err == nil {
m.cacheUserID(c.Request.Context(), token, user.ID)
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
}
// Try database
user, err = m.getUserFromDatabase(token)
if err == nil {
m.cacheUserID(c.Request().Context(), token, user.ID)
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
}
c.Next()
return next(c)
}
}
}
// extractToken extracts the token from the Authorization header
func extractToken(c *gin.Context) (string, error) {
authHeader := c.GetHeader("Authorization")
func extractToken(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return "", fmt.Errorf("authorization header required")
}
@@ -205,32 +200,29 @@ func (m *AuthMiddleware) InvalidateToken(ctx context.Context, token string) erro
return m.cache.InvalidateAuthToken(ctx, token)
}
// GetAuthUser retrieves the authenticated user from the Gin context
func GetAuthUser(c *gin.Context) *models.User {
user, exists := c.Get(AuthUserKey)
if !exists {
// GetAuthUser retrieves the authenticated user from the Echo context
func GetAuthUser(c echo.Context) *models.User {
user := c.Get(AuthUserKey)
if user == nil {
return nil
}
return user.(*models.User)
}
// GetAuthToken retrieves the auth token from the Gin context
func GetAuthToken(c *gin.Context) string {
token, exists := c.Get(AuthTokenKey)
if !exists {
// GetAuthToken retrieves the auth token from the Echo context
func GetAuthToken(c echo.Context) string {
token := c.Get(AuthTokenKey)
if token == nil {
return ""
}
return token.(string)
}
// MustGetAuthUser retrieves the authenticated user or aborts with 401
func MustGetAuthUser(c *gin.Context) *models.User {
// MustGetAuthUser retrieves the authenticated user or returns error with 401
func MustGetAuthUser(c echo.Context) (*models.User, error) {
user := GetAuthUser(c)
if user == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return nil
return nil, apperrors.Unauthorized("error.not_authenticated")
}
return user
return user, nil
}

View File

@@ -3,7 +3,7 @@ package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
const (
@@ -22,21 +22,23 @@ const (
// or a UTC offset (e.g., "-08:00", "+05:30").
//
// If no timezone is provided or it's invalid, UTC is used as the default.
func TimezoneMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tzName := c.GetHeader(TimezoneHeader)
loc := parseTimezone(tzName)
func TimezoneMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
tzName := c.Request().Header.Get(TimezoneHeader)
loc := parseTimezone(tzName)
// Store the location and the current time in that timezone
c.Set(TimezoneKey, loc)
// Store the location and the current time in that timezone
c.Set(TimezoneKey, loc)
// Calculate "now" in the user's timezone, then get start of day
// For date comparisons, we want to compare against the START of the user's current day
userNow := time.Now().In(loc)
startOfDay := time.Date(userNow.Year(), userNow.Month(), userNow.Day(), 0, 0, 0, 0, loc)
c.Set(UserNowKey, startOfDay)
// Calculate "now" in the user's timezone, then get start of day
// For date comparisons, we want to compare against the START of the user's current day
userNow := time.Now().In(loc)
startOfDay := time.Date(userNow.Year(), userNow.Month(), userNow.Day(), 0, 0, 0, 0, loc)
c.Set(UserNowKey, startOfDay)
c.Next()
return next(c)
}
}
}
@@ -76,22 +78,22 @@ func parseTimezone(tz string) *time.Location {
return time.UTC
}
// GetUserTimezone retrieves the user's timezone from the Gin context.
// GetUserTimezone retrieves the user's timezone from the Echo context.
// Returns UTC if not set.
func GetUserTimezone(c *gin.Context) *time.Location {
loc, exists := c.Get(TimezoneKey)
if !exists {
func GetUserTimezone(c echo.Context) *time.Location {
loc := c.Get(TimezoneKey)
if loc == nil {
return time.UTC
}
return loc.(*time.Location)
}
// GetUserNow retrieves the timezone-aware "now" time from the Gin context.
// GetUserNow retrieves the timezone-aware "now" time from the Echo context.
// This represents the start of the current day in the user's timezone.
// Returns time.Now().UTC() if not set.
func GetUserNow(c *gin.Context) time.Time {
now, exists := c.Get(UserNowKey)
if !exists {
func GetUserNow(c echo.Context) time.Time {
now := c.Get(UserNowKey)
if now == nil {
return time.Now().UTC()
}
return now.(time.Time)

View File

@@ -109,14 +109,21 @@ func (Task) TableName() string {
// single source of truth for task logic. It uses EffectiveDate (NextDueDate ?? DueDate)
// rather than just DueDate, ensuring consistency with kanban categorization.
//
// Uses day-based comparison: a task due TODAY is NOT overdue, it only becomes
// overdue the NEXT day.
//
// Deprecated: Prefer using task.IsOverdue(t, time.Now().UTC()) directly for explicit time control.
func (t *Task) IsOverdue() bool {
// Delegate to predicates package - single source of truth
// Import is avoided here to prevent circular dependency.
return t.IsOverdueAt(time.Now().UTC())
}
// IsOverdueAt returns true if the task would be overdue at the given time.
// Uses day-based comparison: a task due on the same day as `now` is NOT overdue.
func (t *Task) IsOverdueAt(now time.Time) bool {
// Logic must match predicates.IsOverdue exactly:
// - Check active (not cancelled, not archived)
// - Check not completed (NextDueDate != nil || no completions)
// - Check effective date < now
// - Check effective date < start of today
if t.IsCancelled || t.IsArchived {
return false
}
@@ -134,7 +141,9 @@ func (t *Task) IsOverdue() bool {
if effectiveDate == nil {
return false
}
return effectiveDate.Before(time.Now().UTC())
// Day-based comparison: compare against start of today
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return effectiveDate.Before(startOfDay)
}
// IsDueSoon returns true if the task is due within the specified days.
@@ -169,6 +178,88 @@ func (t *Task) IsDueSoon(days int) bool {
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
}
// GetKanbanColumn returns the kanban column name for this task using the
// Chain of Responsibility pattern from the categorization package.
// Uses UTC time for categorization.
//
// For timezone-aware categorization, use GetKanbanColumnWithTimezone.
func (t *Task) GetKanbanColumn(daysThreshold int) string {
// Import would cause circular dependency, so we inline the logic
// This delegates to the categorization package via internal/task re-export
return t.GetKanbanColumnWithTimezone(daysThreshold, time.Now().UTC())
}
// GetKanbanColumnWithTimezone returns the kanban column name using a specific
// time (in the user's timezone). The time is used to determine "today" for
// overdue/due-soon calculations.
//
// Example: For a user in Tokyo, pass time.Now().In(tokyoLocation) to get
// accurate categorization relative to their local date.
func (t *Task) GetKanbanColumnWithTimezone(daysThreshold int, now time.Time) string {
// Note: We can't import categorization directly due to circular dependency.
// Instead, this method implements the categorization logic inline.
// The logic MUST match categorization.Chain exactly.
if daysThreshold <= 0 {
daysThreshold = 30
}
// Start of day normalization
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
threshold := startOfDay.AddDate(0, 0, daysThreshold)
// Priority 1: Cancelled
if t.IsCancelled {
return "cancelled_tasks"
}
// Priority 2: Archived (goes to cancelled column - both are "inactive" states)
if t.IsArchived {
return "cancelled_tasks"
}
// Priority 3: Completed (NextDueDate nil with completions)
hasCompletions := len(t.Completions) > 0 || t.CompletionCount > 0
if t.NextDueDate == nil && hasCompletions {
return "completed_tasks"
}
// Priority 4: In Progress
if t.InProgress {
return "in_progress_tasks"
}
// Get effective date: NextDueDate ?? DueDate
var effectiveDate *time.Time
if t.NextDueDate != nil {
effectiveDate = t.NextDueDate
} else {
effectiveDate = t.DueDate
}
if effectiveDate != nil {
// Normalize effective date to same timezone for calendar date comparison
// Task dates are stored as UTC but represent calendar dates (YYYY-MM-DD)
normalizedEffective := time.Date(
effectiveDate.Year(), effectiveDate.Month(), effectiveDate.Day(),
0, 0, 0, 0, now.Location(),
)
// Priority 5: Overdue (effective date before today)
if normalizedEffective.Before(startOfDay) {
return "overdue_tasks"
}
// Priority 6: Due Soon (effective date before threshold)
if normalizedEffective.Before(threshold) {
return "due_soon_tasks"
}
}
// Priority 7: Upcoming (default)
return "upcoming_tasks"
}
// TaskCompletion represents the task_taskcompletion table
type TaskCompletion struct {
BaseModel

View File

@@ -247,3 +247,197 @@ func TestDocument_JSONSerialization(t *testing.T) {
assert.Equal(t, "HVAC-123", result["serial_number"])
assert.Equal(t, "5000", result["purchase_price"]) // Decimal serializes as string
}
// ============================================================================
// TASK KANBAN COLUMN TESTS
// These tests verify GetKanbanColumn and GetKanbanColumnWithTimezone methods
// ============================================================================
func timePtr(t time.Time) *time.Time {
return &t
}
func TestTask_GetKanbanColumn_PriorityOrder(t *testing.T) {
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
yesterday := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
in5Days := time.Date(2025, 12, 21, 0, 0, 0, 0, time.UTC)
in60Days := time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
task *Task
expected string
}{
// Priority 1: Cancelled
{
name: "cancelled takes highest priority",
task: &Task{
IsCancelled: true,
NextDueDate: timePtr(yesterday),
InProgress: true,
},
expected: "cancelled_tasks",
},
// Priority 2: Completed
{
name: "completed: NextDueDate nil with completions",
task: &Task{
IsCancelled: false,
NextDueDate: nil,
DueDate: timePtr(yesterday),
Completions: []TaskCompletion{{BaseModel: BaseModel{ID: 1}}},
},
expected: "completed_tasks",
},
// Priority 3: In Progress
{
name: "in progress takes priority over overdue",
task: &Task{
IsCancelled: false,
NextDueDate: timePtr(yesterday),
InProgress: true,
},
expected: "in_progress_tasks",
},
// Priority 4: Overdue
{
name: "overdue: effective date in past",
task: &Task{
IsCancelled: false,
NextDueDate: timePtr(yesterday),
},
expected: "overdue_tasks",
},
// Priority 5: Due Soon
{
name: "due soon: within 30-day threshold",
task: &Task{
IsCancelled: false,
NextDueDate: timePtr(in5Days),
},
expected: "due_soon_tasks",
},
// Priority 6: Upcoming
{
name: "upcoming: beyond threshold",
task: &Task{
IsCancelled: false,
NextDueDate: timePtr(in60Days),
},
expected: "upcoming_tasks",
},
{
name: "upcoming: no due date",
task: &Task{
IsCancelled: false,
NextDueDate: nil,
DueDate: nil,
},
expected: "upcoming_tasks",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.task.GetKanbanColumnWithTimezone(30, now)
assert.Equal(t, tt.expected, result)
})
}
}
func TestTask_GetKanbanColumnWithTimezone_TimezoneAware(t *testing.T) {
// Task due Dec 17, 2025
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
}
// At 11 PM UTC on Dec 16 (UTC user) - task is tomorrow, due_soon
utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
result := task.GetKanbanColumnWithTimezone(30, utcDec16Evening)
assert.Equal(t, "due_soon_tasks", result, "UTC Dec 16 evening")
// At 8 AM UTC on Dec 17 (UTC user) - task is today, due_soon
utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
result = task.GetKanbanColumnWithTimezone(30, utcDec17Morning)
assert.Equal(t, "due_soon_tasks", result, "UTC Dec 17 morning")
// At 8 AM UTC on Dec 18 (UTC user) - task was yesterday, overdue
utcDec18Morning := time.Date(2025, 12, 18, 8, 0, 0, 0, time.UTC)
result = task.GetKanbanColumnWithTimezone(30, utcDec18Morning)
assert.Equal(t, "overdue_tasks", result, "UTC Dec 18 morning")
// Tokyo user at 11 PM UTC Dec 16 = 8 AM Dec 17 Tokyo
// Task due Dec 17 is TODAY for Tokyo user - due_soon
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoDec17Morning := utcDec16Evening.In(tokyo)
result = task.GetKanbanColumnWithTimezone(30, tokyoDec17Morning)
assert.Equal(t, "due_soon_tasks", result, "Tokyo Dec 17 morning")
// Tokyo at 8 AM Dec 18 UTC = 5 PM Dec 18 Tokyo
// Task due Dec 17 was YESTERDAY for Tokyo - overdue
tokyoDec18 := utcDec18Morning.In(tokyo)
result = task.GetKanbanColumnWithTimezone(30, tokyoDec18)
assert.Equal(t, "overdue_tasks", result, "Tokyo Dec 18")
}
func TestTask_GetKanbanColumnWithTimezone_DueSoonThreshold(t *testing.T) {
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
// Task due in 29 days - within 30-day threshold
due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
task29 := &Task{NextDueDate: timePtr(due29Days)}
result := task29.GetKanbanColumnWithTimezone(30, now)
assert.Equal(t, "due_soon_tasks", result, "29 days should be due_soon")
// Task due in exactly 30 days - at threshold boundary (upcoming, not due_soon)
due30Days := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
task30 := &Task{NextDueDate: timePtr(due30Days)}
result = task30.GetKanbanColumnWithTimezone(30, now)
assert.Equal(t, "upcoming_tasks", result, "30 days should be upcoming (at boundary)")
// Task due in 31 days - beyond threshold
due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
task31 := &Task{NextDueDate: timePtr(due31Days)}
result = task31.GetKanbanColumnWithTimezone(30, now)
assert.Equal(t, "upcoming_tasks", result, "31 days should be upcoming")
}
func TestTask_GetKanbanColumn_CompletionCount(t *testing.T) {
// Test that CompletionCount is also used for completion detection
task := &Task{
NextDueDate: nil,
CompletionCount: 1, // Using CompletionCount instead of Completions slice
Completions: []TaskCompletion{},
}
result := task.GetKanbanColumn(30)
assert.Equal(t, "completed_tasks", result)
}
func TestTask_IsOverdueAt_DayBased(t *testing.T) {
// Test that IsOverdueAt uses day-based comparison
now := time.Date(2025, 12, 16, 15, 0, 0, 0, time.UTC) // 3 PM UTC
// Task due today (midnight) - NOT overdue
todayMidnight := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
taskToday := &Task{NextDueDate: timePtr(todayMidnight)}
assert.False(t, taskToday.IsOverdueAt(now), "Task due today should NOT be overdue")
// Task due yesterday - IS overdue
yesterday := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
taskYesterday := &Task{NextDueDate: timePtr(yesterday)}
assert.True(t, taskYesterday.IsOverdueAt(now), "Task due yesterday should be overdue")
// Task due tomorrow - NOT overdue
tomorrow := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
taskTomorrow := &Task{NextDueDate: timePtr(tomorrow)}
assert.False(t, taskTomorrow.IsOverdueAt(now), "Task due tomorrow should NOT be overdue")
}

View File

@@ -8,7 +8,7 @@ import (
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
@@ -38,11 +38,10 @@ func NewHandler(logBuffer *LogBuffer, statsStore *StatsStore) *Handler {
// GetLogs returns filtered log entries
// GET /api/admin/monitoring/logs
func (h *Handler) GetLogs(c *gin.Context) {
func (h *Handler) GetLogs(c echo.Context) error {
var filters LogFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters"})
return
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid filters"})
}
limit := filters.GetLimit()
@@ -51,8 +50,7 @@ func (h *Handler) GetLogs(c *gin.Context) {
entries, err := h.logBuffer.GetRecent(limit * 2)
if err != nil {
log.Error().Err(err).Msg("Failed to get logs from buffer")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to retrieve logs"})
}
// Apply filters
@@ -83,7 +81,7 @@ func (h *Handler) GetLogs(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
return c.JSON(http.StatusOK, map[string]interface{}{
"logs": filtered,
"total": len(filtered),
})
@@ -91,41 +89,38 @@ func (h *Handler) GetLogs(c *gin.Context) {
// GetStats returns system statistics for all processes
// GET /api/admin/monitoring/stats
func (h *Handler) GetStats(c *gin.Context) {
func (h *Handler) GetStats(c echo.Context) error {
allStats, err := h.statsStore.GetAllStats()
if err != nil {
log.Error().Err(err).Msg("Failed to get stats from store")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve stats"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to retrieve stats"})
}
c.JSON(http.StatusOK, allStats)
return c.JSON(http.StatusOK, allStats)
}
// ClearLogs clears all logs from the buffer
// DELETE /api/admin/monitoring/logs
func (h *Handler) ClearLogs(c *gin.Context) {
func (h *Handler) ClearLogs(c echo.Context) error {
if err := h.logBuffer.Clear(); err != nil {
log.Error().Err(err).Msg("Failed to clear logs")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear logs"})
return
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to clear logs"})
}
c.JSON(http.StatusOK, gin.H{"message": "Logs cleared"})
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Logs cleared"})
}
// WebSocket handles real-time log streaming
// GET /api/admin/monitoring/ws
func (h *Handler) WebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
func (h *Handler) WebSocket(c echo.Context) error {
conn, err := upgrader.Upgrade(c.Response().Writer, c.Request(), nil)
if err != nil {
log.Error().Err(err).Msg("Failed to upgrade WebSocket connection")
return
}
defer conn.Close()
// Create context that cancels when connection closes
ctx, cancel := context.WithCancel(c.Request.Context())
ctx, cancel := context.WithCancel(c.Request().Context())
defer cancel()
// Subscribe to Redis pubsub for logs
@@ -139,7 +134,6 @@ func (h *Handler) WebSocket(c *gin.Context) {
_, _, err := conn.ReadMessage()
if err != nil {
cancel()
return
}
}
}()
@@ -173,7 +167,6 @@ func (h *Handler) WebSocket(c *gin.Context) {
if err != nil {
log.Debug().Err(err).Msg("WebSocket write error")
return
}
case <-statsTicker.C:
@@ -181,7 +174,6 @@ func (h *Handler) WebSocket(c *gin.Context) {
h.sendStats(conn, &wsMu)
case <-ctx.Done():
return
}
}
}
@@ -189,7 +181,6 @@ func (h *Handler) WebSocket(c *gin.Context) {
func (h *Handler) sendStats(conn *websocket.Conn, mu *sync.Mutex) {
allStats, err := h.statsStore.GetAllStats()
if err != nil {
return
}
wsMsg := WSMessage{

View File

@@ -5,7 +5,7 @@ import (
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
)
// HTTPStatsCollector collects HTTP request metrics
@@ -189,27 +189,31 @@ func (c *HTTPStatsCollector) Reset() {
c.startTime = time.Now()
}
// MetricsMiddleware returns a Gin middleware that collects request metrics
func MetricsMiddleware(collector *HTTPStatsCollector) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// MetricsMiddleware returns an Echo middleware that collects request metrics
func MetricsMiddleware(collector *HTTPStatsCollector) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
// Process request
c.Next()
// Process request
err := next(c)
// Calculate latency
latency := time.Since(start)
// Calculate latency
latency := time.Since(start)
// Get endpoint pattern (use route path, fallback to actual path)
endpoint := c.FullPath()
if endpoint == "" {
endpoint = c.Request.URL.Path
// Get endpoint pattern (use route path, fallback to actual path)
endpoint := c.Path()
if endpoint == "" {
endpoint = c.Request().URL.Path
}
// Combine method with path for unique endpoint identification
endpoint = c.Request().Method + " " + endpoint
// Record metrics
collector.Record(endpoint, latency, c.Response().Status)
return err
}
// Combine method with path for unique endpoint identification
endpoint = c.Request.Method + " " + endpoint
// Record metrics
collector.Record(endpoint, latency, c.Writer.Status())
}
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/hibiken/asynq"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -185,8 +186,8 @@ func (s *Service) HTTPCollector() *HTTPStatsCollector {
return s.httpCollector
}
// MetricsMiddleware returns the Gin middleware for HTTP metrics (API server only)
func (s *Service) MetricsMiddleware() interface{} {
// MetricsMiddleware returns the Echo middleware for HTTP metrics (API server only)
func (s *Service) MetricsMiddleware() echo.MiddlewareFunc {
if s.httpCollector == nil {
return nil
}

View File

@@ -1,6 +1,7 @@
package repositories
import (
"fmt"
"time"
"gorm.io/gorm"
@@ -20,6 +21,193 @@ func NewTaskRepository(db *gorm.DB) *TaskRepository {
return &TaskRepository{db: db}
}
// === Task Filter Options ===
// TaskFilterOptions provides flexible filtering for task queries.
// Use exactly one of ResidenceID, ResidenceIDs, or UserIDs to specify the filter scope.
type TaskFilterOptions struct {
// Filter by single residence (kanban single-residence view)
ResidenceID uint
// Filter by multiple residences (kanban all-residences view)
ResidenceIDs []uint
// Filter by users - matches tasks where assigned_to IN userIDs
// OR residence owner IN userIDs (for notifications)
UserIDs []uint
// Include archived tasks (default: false, excludes archived)
IncludeArchived bool
// IncludeInProgress controls whether in-progress tasks are included in
// overdue/due-soon/upcoming queries. Default is false (excludes in-progress)
// for kanban column consistency. Set to true for notifications where
// users should still be notified about in-progress tasks that are overdue.
IncludeInProgress bool
// Preload options
PreloadCreatedBy bool
PreloadAssignedTo bool
PreloadResidence bool
PreloadCompletions bool // Minimal: just id, task_id, completed_at
}
// applyFilterOptions applies the filter options to a query.
// Returns a new query with filters and preloads applied.
func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptions) *gorm.DB {
// Apply residence/user filters
if opts.ResidenceID != 0 {
query = query.Where("task_task.residence_id = ?", opts.ResidenceID)
} else if len(opts.ResidenceIDs) > 0 {
query = query.Where("task_task.residence_id IN ?", opts.ResidenceIDs)
} else if len(opts.UserIDs) > 0 {
// For notifications: tasks assigned to users OR owned by users
query = query.Where(
"(task_task.assigned_to_id IN ? OR task_task.residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
opts.UserIDs, opts.UserIDs,
)
}
// Apply archived filter (default excludes archived)
if !opts.IncludeArchived {
query = query.Where("task_task.is_archived = ?", false)
}
// Apply preloads
if opts.PreloadCreatedBy {
query = query.Preload("CreatedBy")
}
if opts.PreloadAssignedTo {
query = query.Preload("AssignedTo")
}
if opts.PreloadResidence {
query = query.Preload("Residence")
}
if opts.PreloadCompletions {
query = query.Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
})
}
return query
}
// === Single-Purpose Task Query Functions ===
// These functions use the scopes from internal/task/scopes for consistent filtering.
// They are the single source of truth for task categorization queries, used by both
// kanban and notification handlers.
// GetOverdueTasks returns active, non-completed tasks past their effective due date.
// Uses task.ScopeOverdue for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate overdue detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetOverdueTasks(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{})
if opts.IncludeArchived {
// When including archived, build the query manually to skip the archived check
// but still apply cancelled check, not-completed check, and date check
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
query = query.Where("is_cancelled = ?", false).
Scopes(task.ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
} else {
// Use the combined scope which includes is_archived = false
query = query.Scopes(task.ScopeOverdue(now))
}
query = query.Scopes(task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetDueSoonTasks returns active, non-completed tasks due within the threshold.
// Uses task.ScopeDueSoon for consistent filtering logic.
// The `now` parameter should be in the user's timezone for accurate detection.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetDueSoonTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeDueSoon(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetInProgressTasks returns active, non-completed tasks marked as in-progress.
// Uses task.ScopeInProgress for consistent filtering logic.
//
// Note: Excludes completed tasks to match kanban column behavior (completed has higher priority).
func (r *TaskRepository) GetInProgressTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeActive, task.ScopeNotCompleted, task.ScopeInProgress, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetUpcomingTasks returns active, non-completed tasks due after the threshold or with no due date.
// Uses task.ScopeUpcoming for consistent filtering logic.
//
// By default, excludes in-progress tasks for kanban column consistency.
// Set opts.IncludeInProgress=true for notifications where in-progress tasks should still appear.
func (r *TaskRepository) GetUpcomingTasks(now time.Time, daysThreshold int, opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
query := r.db.Model(&models.Task{}).
Scopes(task.ScopeUpcoming(now, daysThreshold), task.ScopeKanbanOrder)
if !opts.IncludeInProgress {
query = query.Scopes(task.ScopeNotInProgress)
}
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCompletedTasks returns completed tasks (NextDueDate nil with at least one completion).
// Uses task.ScopeCompleted for consistent filtering logic.
func (r *TaskRepository) GetCompletedTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Completed tasks: not cancelled, has completion, no next due date
// Note: We don't apply ScopeActive because completed tasks may not be "active" in that sense
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ?", false).
Scopes(task.ScopeCompleted, task.ScopeKanbanOrder)
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// GetCancelledTasks returns cancelled OR archived tasks.
// Archived tasks are grouped with cancelled for kanban purposes - they both represent
// tasks that are no longer active/actionable.
func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Task, error) {
var tasks []models.Task
// Include both cancelled and archived tasks in this column
// Archived tasks should ONLY appear here, not in any other column
query := r.db.Model(&models.Task{}).
Where("is_cancelled = ? OR is_archived = ?", true, true).
Scopes(task.ScopeKanbanOrder)
// Override IncludeArchived to true since this function specifically handles archived tasks
opts.IncludeArchived = true
query = r.applyFilterOptions(query, opts)
err := query.Find(&tasks).Error
return tasks, err
}
// === Task CRUD ===
// FindByID finds a task by ID with preloaded relations
@@ -125,180 +313,175 @@ func (r *TaskRepository) Unarchive(id uint) error {
// === Kanban Board ===
// buildKanbanColumns builds the kanban column array from categorized task slices.
// This is a helper function to reduce duplication between GetKanbanData and GetKanbanDataForMultipleResidences.
func buildKanbanColumns(
overdue, inProgress, dueSoon, upcoming, completed, cancelled []models.Task,
) []models.KanbanColumn {
return []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: overdue,
Count: len(overdue),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: inProgress,
Count: len(inProgress),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: dueSoon,
Count: len(dueSoon),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: upcoming,
Count: len(upcoming),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: completed,
Count: len(completed),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: cancelled,
Count: len(cancelled),
},
}
}
// GetKanbanData retrieves tasks organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Where("residence_id = ? AND is_archived = ?", residenceID, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
opts := TaskFilterOptions{
ResidenceID: residenceID,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, err
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
ResidenceID: string(rune(residenceID)),
ResidenceID: fmt.Sprintf("%d", residenceID),
}, nil
}
// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display.
// Uses the task.categorization package as the single source of truth for categorization logic.
// Uses single-purpose query functions for each column type, ensuring consistency
// with notification handlers that use the same functions.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// Optimization: Preloads only minimal completion data (id, task_id, completed_at) for count/detection.
// Images and CompletedBy are NOT preloaded - fetch separately when viewing completion details.
func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
var tasks []models.Task
// Note: Category, Priority, Frequency are NOT preloaded - client resolves from cache using IDs
// Optimization: Preload only minimal Completions data (no Images, no CompletedBy)
err := r.db.Preload("CreatedBy").
Preload("AssignedTo").
Preload("Residence").
Preload("Completions", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "task_id", "completed_at")
}).
Where("residence_id IN ? AND is_archived = ?", residenceIDs, false).
Scopes(task.ScopeKanbanOrder).
Find(&tasks).Error
opts := TaskFilterOptions{
ResidenceIDs: residenceIDs,
PreloadCreatedBy: true,
PreloadAssignedTo: true,
PreloadResidence: true,
PreloadCompletions: true,
}
// Query each column using single-purpose functions
// These functions use the same scopes as notification handlers for consistency
overdue, err := r.GetOverdueTasks(now, opts)
if err != nil {
return nil, err
return nil, fmt.Errorf("get overdue tasks: %w", err)
}
// Use the categorization package as the single source of truth
// Pass the user's timezone-aware time for accurate overdue detection
categorized := categorization.CategorizeTasksIntoColumnsWithTime(tasks, daysThreshold, now)
columns := []models.KanbanColumn{
{
Name: string(categorization.ColumnOverdue),
DisplayName: "Overdue",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"},
Color: "#FF3B30",
Tasks: categorized[categorization.ColumnOverdue],
Count: len(categorized[categorization.ColumnOverdue]),
},
{
Name: string(categorization.ColumnInProgress),
DisplayName: "In Progress",
ButtonTypes: []string{"edit", "complete", "cancel"},
Icons: map[string]string{"ios": "hammer", "android": "Build"},
Color: "#5856D6",
Tasks: categorized[categorization.ColumnInProgress],
Count: len(categorized[categorization.ColumnInProgress]),
},
{
Name: string(categorization.ColumnDueSoon),
DisplayName: "Due Soon",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "clock", "android": "Schedule"},
Color: "#FF9500",
Tasks: categorized[categorization.ColumnDueSoon],
Count: len(categorized[categorization.ColumnDueSoon]),
},
{
Name: string(categorization.ColumnUpcoming),
DisplayName: "Upcoming",
ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"},
Icons: map[string]string{"ios": "calendar", "android": "Event"},
Color: "#007AFF",
Tasks: categorized[categorization.ColumnUpcoming],
Count: len(categorized[categorization.ColumnUpcoming]),
},
{
Name: string(categorization.ColumnCompleted),
DisplayName: "Completed",
ButtonTypes: []string{},
Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"},
Color: "#34C759",
Tasks: categorized[categorization.ColumnCompleted],
Count: len(categorized[categorization.ColumnCompleted]),
},
{
Name: string(categorization.ColumnCancelled),
DisplayName: "Cancelled",
ButtonTypes: []string{"uncancel", "delete"},
Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"},
Color: "#8E8E93",
Tasks: categorized[categorization.ColumnCancelled],
Count: len(categorized[categorization.ColumnCancelled]),
},
inProgress, err := r.GetInProgressTasks(opts)
if err != nil {
return nil, fmt.Errorf("get in-progress tasks: %w", err)
}
dueSoon, err := r.GetDueSoonTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get due-soon tasks: %w", err)
}
upcoming, err := r.GetUpcomingTasks(now, daysThreshold, opts)
if err != nil {
return nil, fmt.Errorf("get upcoming tasks: %w", err)
}
completed, err := r.GetCompletedTasks(opts)
if err != nil {
return nil, fmt.Errorf("get completed tasks: %w", err)
}
cancelled, err := r.GetCancelledTasks(opts)
if err != nil {
return nil, fmt.Errorf("get cancelled tasks: %w", err)
}
columns := buildKanbanColumns(overdue, inProgress, dueSoon, upcoming, completed, cancelled)
return &models.KanbanBoard{
Columns: columns,
DaysThreshold: daysThreshold,
@@ -419,83 +602,6 @@ func (r *TaskRepository) FindCompletionImageByID(id uint) (*models.TaskCompletio
return &image, nil
}
// TaskStatistics represents aggregated task statistics
type TaskStatistics struct {
TotalTasks int
TotalPending int
TotalOverdue int
TasksDueNextWeek int
TasksDueNextMonth int
}
// GetTaskStatistics returns aggregated task statistics for multiple residences.
// Uses a single optimized query with CASE statements instead of 5 separate queries.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint, now time.Time) (*TaskStatistics, error) {
if len(residenceIDs) == 0 {
return &TaskStatistics{}, nil
}
nextWeek := now.AddDate(0, 0, 7)
nextMonth := now.AddDate(0, 0, 30)
// Single query with CASE statements to count all statistics at once
// This replaces 5 separate COUNT queries with 1 query
type statsResult struct {
TotalTasks int64
TotalOverdue int64
TotalPending int64
TasksDueNextWeek int64
TasksDueNextMonth int64
}
var result statsResult
// Build the optimized query
// Base conditions: active (not cancelled, not archived), in specified residences
// NotCompleted: NOT (next_due_date IS NULL AND has completions)
err := r.db.Model(&models.Task{}).
Select(`
COUNT(*) as total_tasks,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as total_overdue,
COUNT(CASE
WHEN NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as total_pending,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as tasks_due_next_week,
COUNT(CASE
WHEN COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
AND NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))
THEN 1
END) as tasks_due_next_month
`, now, now, nextWeek, now, nextMonth).
Where("residence_id IN ?", residenceIDs).
Where("is_cancelled = ? AND is_archived = ?", false, false).
Scan(&result).Error
if err != nil {
return nil, err
}
return &TaskStatistics{
TotalTasks: int(result.TotalTasks),
TotalPending: int(result.TotalPending),
TotalOverdue: int(result.TotalOverdue),
TasksDueNextWeek: int(result.TasksDueNextWeek),
TasksDueNextMonth: int(result.TasksDueNextMonth),
}, nil
}
// GetOverdueCountByResidence returns a map of residence ID to overdue task count.
// Uses the task.scopes package for consistent filtering logic.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,29 @@
package router
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/admin"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/handlers"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
custommiddleware "github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/monitoring"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
customvalidator "github.com/treytartt/casera-api/internal/validator"
"github.com/treytartt/casera-api/pkg/utils"
)
@@ -34,55 +41,54 @@ type Dependencies struct {
MonitoringService *monitoring.Service
}
// SetupRouter creates and configures the Gin router
func SetupRouter(deps *Dependencies) *gin.Engine {
// SetupRouter creates and configures the Echo router
func SetupRouter(deps *Dependencies) *echo.Echo {
cfg := deps.Config
// Set Gin mode based on debug setting
if cfg.Server.Debug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
e := echo.New()
e.HideBanner = true
e.Validator = customvalidator.NewCustomValidator()
e.HTTPErrorHandler = customHTTPErrorHandler
r := gin.New()
// Add trailing slash middleware (before other middleware)
e.Pre(middleware.AddTrailingSlash())
// Global middleware
r.Use(utils.GinRecovery())
r.Use(utils.GinLogger())
r.Use(corsMiddleware(cfg))
r.Use(i18n.Middleware())
e.Use(utils.EchoRecovery())
e.Use(utils.EchoLogger())
e.Use(corsMiddleware(cfg))
e.Use(i18n.Middleware())
// Monitoring metrics middleware (if monitoring is enabled)
if deps.MonitoringService != nil {
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
r.Use(metricsMiddleware.(gin.HandlerFunc))
e.Use(metricsMiddleware)
}
}
// Serve landing page static files (if static directory is configured)
staticDir := cfg.Server.StaticDir
if staticDir != "" {
r.Static("/css", staticDir+"/css")
r.Static("/js", staticDir+"/js")
r.Static("/images", staticDir+"/images")
r.StaticFile("/favicon.ico", staticDir+"/images/favicon.svg")
e.Static("/css", staticDir+"/css")
e.Static("/js", staticDir+"/js")
e.Static("/images", staticDir+"/images")
e.File("/favicon.ico", staticDir+"/images/favicon.svg")
// Serve index.html at root
r.GET("/", func(c *gin.Context) {
c.File(staticDir + "/index.html")
e.GET("/", func(c echo.Context) error {
return c.File(staticDir + "/index.html")
})
}
// Health check endpoint (no auth required)
r.GET("/api/health/", healthCheck)
e.GET("/api/health/", healthCheck)
// Initialize onboarding email service for tracking handler
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
// Email tracking endpoint (no auth required - used by email tracking pixels)
trackingHandler := handlers.NewTrackingHandler(onboardingService)
r.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
e.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
// NOTE: Public static file serving removed for security.
// All uploaded media is now served through authenticated proxy endpoints:
@@ -123,7 +129,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
subscriptionWebhookHandler := handlers.NewSubscriptionWebhookHandler(subscriptionRepo, userRepo)
// Initialize middleware
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
authMiddleware := custommiddleware.NewAuthMiddleware(deps.DB, deps.Cache)
// Initialize Apple auth service
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
@@ -161,10 +167,10 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
OnboardingService: onboardingService,
MonitoringHandler: monitoringHandler,
}
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)
admin.SetupRoutes(e, deps.DB, cfg, adminDeps)
// API group
api := r.Group("/api")
api := e.Group("/api")
{
// Public auth routes (no auth required)
setupPublicAuthRoutes(api, authHandler)
@@ -178,7 +184,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
// Protected routes (auth required)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.Use(middleware.TimezoneMiddleware())
protected.Use(custommiddleware.TimezoneMiddleware())
{
setupProtectedAuthRoutes(protected, authHandler)
setupResidenceRoutes(protected, residenceHandler)
@@ -201,33 +207,33 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
}
}
return r
return e
}
// corsMiddleware configures CORS - allowing all origins for API access
func corsMiddleware(cfg *config.Config) gin.HandlerFunc {
return cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Timezone"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false, // Must be false when AllowAllOrigins is true
MaxAge: 12 * time.Hour,
func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
return middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Requested-With", "X-Timezone"},
ExposeHeaders: []string{echo.HeaderContentLength},
AllowCredentials: false,
MaxAge: int((12 * time.Hour).Seconds()),
})
}
// healthCheck returns API health status
func healthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
func healthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "healthy",
"version": Version,
"framework": "Gin",
"framework": "Echo",
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// setupPublicAuthRoutes configures public authentication routes
func setupPublicAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandler) {
func setupPublicAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
auth := api.Group("/auth")
{
auth.POST("/login/", authHandler.Login)
@@ -240,7 +246,7 @@ func setupPublicAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandl
}
// setupProtectedAuthRoutes configures protected authentication routes
func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandler) {
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
auth := api.Group("/auth")
{
auth.POST("/logout/", authHandler.Logout)
@@ -254,7 +260,7 @@ func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHa
}
// setupPublicDataRoutes configures public data routes (lookups, static data)
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
// Static data routes (public, cached)
staticData := api.Group("/static_data")
{
@@ -287,7 +293,7 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi
}
// setupResidenceRoutes configures residence routes
func setupResidenceRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler) {
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler) {
residences := api.Group("/residences")
{
residences.GET("/", residenceHandler.ListResidences)
@@ -310,7 +316,7 @@ func setupResidenceRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resid
}
// setupTaskRoutes configures task routes
func setupTaskRoutes(api *gin.RouterGroup, taskHandler *handlers.TaskHandler) {
func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) {
tasks := api.Group("/tasks")
{
tasks.GET("/", taskHandler.ListTasks)
@@ -342,7 +348,7 @@ func setupTaskRoutes(api *gin.RouterGroup, taskHandler *handlers.TaskHandler) {
}
// setupContractorRoutes configures contractor routes
func setupContractorRoutes(api *gin.RouterGroup, contractorHandler *handlers.ContractorHandler) {
func setupContractorRoutes(api *echo.Group, contractorHandler *handlers.ContractorHandler) {
contractors := api.Group("/contractors")
{
contractors.GET("/", contractorHandler.ListContractors)
@@ -358,7 +364,7 @@ func setupContractorRoutes(api *gin.RouterGroup, contractorHandler *handlers.Con
}
// setupDocumentRoutes configures document routes
func setupDocumentRoutes(api *gin.RouterGroup, documentHandler *handlers.DocumentHandler) {
func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHandler) {
documents := api.Group("/documents")
{
documents.GET("/", documentHandler.ListDocuments)
@@ -374,7 +380,7 @@ func setupDocumentRoutes(api *gin.RouterGroup, documentHandler *handlers.Documen
}
// setupNotificationRoutes configures notification routes
func setupNotificationRoutes(api *gin.RouterGroup, notificationHandler *handlers.NotificationHandler) {
func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.NotificationHandler) {
notifications := api.Group("/notifications")
{
notifications.GET("/", notificationHandler.ListNotifications)
@@ -395,7 +401,7 @@ func setupNotificationRoutes(api *gin.RouterGroup, notificationHandler *handlers
// setupSubscriptionRoutes configures subscription routes (authenticated)
// Note: /upgrade-triggers/ is in setupPublicDataRoutes (public, no auth required)
func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers.SubscriptionHandler) {
func setupSubscriptionRoutes(api *echo.Group, subscriptionHandler *handlers.SubscriptionHandler) {
subscription := api.Group("/subscription")
{
subscription.GET("/", subscriptionHandler.GetSubscription)
@@ -410,7 +416,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
}
// setupUserRoutes configures user routes
func setupUserRoutes(api *gin.RouterGroup, userHandler *handlers.UserHandler) {
func setupUserRoutes(api *echo.Group, userHandler *handlers.UserHandler) {
users := api.Group("/users")
{
users.GET("/", userHandler.ListUsers)
@@ -420,7 +426,7 @@ func setupUserRoutes(api *gin.RouterGroup, userHandler *handlers.UserHandler) {
}
// setupUploadRoutes configures file upload routes
func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandler) {
func setupUploadRoutes(api *echo.Group, uploadHandler *handlers.UploadHandler) {
uploads := api.Group("/uploads")
{
uploads.POST("/image/", uploadHandler.UploadImage)
@@ -431,7 +437,7 @@ func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandl
}
// setupMediaRoutes configures authenticated media serving routes
func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler) {
func setupMediaRoutes(api *echo.Group, mediaHandler *handlers.MediaHandler) {
media := api.Group("/media")
{
media.GET("/document/:id", mediaHandler.ServeDocument)
@@ -442,10 +448,145 @@ func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler)
// setupWebhookRoutes configures subscription webhook routes for Apple/Google server-to-server notifications
// These routes are public (no auth) since they're called by Apple/Google servers
func setupWebhookRoutes(api *gin.RouterGroup, webhookHandler *handlers.SubscriptionWebhookHandler) {
func setupWebhookRoutes(api *echo.Group, webhookHandler *handlers.SubscriptionWebhookHandler) {
webhooks := api.Group("/subscription/webhook")
{
webhooks.POST("/apple/", webhookHandler.HandleAppleWebhook)
webhooks.POST("/google/", webhookHandler.HandleGoogleWebhook)
}
}
// customHTTPErrorHandler handles all errors returned from handlers in a consistent way.
// It converts AppErrors, validation errors, and Echo HTTPErrors to JSON responses.
// Also includes fallback handling for legacy service-level errors.
func customHTTPErrorHandler(err error, c echo.Context) {
// Already committed? Skip
if c.Response().Committed {
return
}
// Handle AppError (our custom application errors)
var appErr *apperrors.AppError
if errors.As(err, &appErr) {
message := i18n.LocalizedMessage(c, appErr.MessageKey)
// If i18n key not found (returns the key itself), use fallback message
if message == appErr.MessageKey && appErr.Message != "" {
message = appErr.Message
} else if message == appErr.MessageKey {
message = appErr.MessageKey // Use the key as last resort
}
// Log internal errors
if appErr.Err != nil {
log.Error().Err(appErr.Err).Str("message_key", appErr.MessageKey).Msg("Application error")
}
c.JSON(appErr.Code, responses.ErrorResponse{Error: message})
return
}
// Handle validation errors from go-playground/validator
var validationErrs validator.ValidationErrors
if errors.As(err, &validationErrs) {
c.JSON(http.StatusBadRequest, customvalidator.FormatValidationErrors(err))
return
}
// Handle Echo's built-in HTTPError
var httpErr *echo.HTTPError
if errors.As(err, &httpErr) {
msg := fmt.Sprintf("%v", httpErr.Message)
c.JSON(httpErr.Code, responses.ErrorResponse{Error: msg})
return
}
// Handle service-layer errors and map them to appropriate HTTP status codes
switch {
// Task errors - 404 Not Found
case errors.Is(err, services.ErrTaskNotFound):
c.JSON(http.StatusNotFound, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.task_not_found"),
})
return
case errors.Is(err, services.ErrCompletionNotFound):
c.JSON(http.StatusNotFound, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.completion_not_found"),
})
return
// Task errors - 403 Forbidden
case errors.Is(err, services.ErrTaskAccessDenied):
c.JSON(http.StatusForbidden, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.task_access_denied"),
})
return
// Task errors - 400 Bad Request
case errors.Is(err, services.ErrTaskAlreadyCancelled):
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.task_already_cancelled"),
})
return
case errors.Is(err, services.ErrTaskAlreadyArchived):
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.task_already_archived"),
})
return
// Residence errors - 404 Not Found
case errors.Is(err, services.ErrResidenceNotFound):
c.JSON(http.StatusNotFound, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.residence_not_found"),
})
return
// Residence errors - 403 Forbidden
case errors.Is(err, services.ErrResidenceAccessDenied):
c.JSON(http.StatusForbidden, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.residence_access_denied"),
})
return
case errors.Is(err, services.ErrNotResidenceOwner):
c.JSON(http.StatusForbidden, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.not_residence_owner"),
})
return
case errors.Is(err, services.ErrPropertiesLimitReached):
c.JSON(http.StatusForbidden, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.properties_limit_reached"),
})
return
// Residence errors - 400 Bad Request
case errors.Is(err, services.ErrCannotRemoveOwner):
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.cannot_remove_owner"),
})
return
case errors.Is(err, services.ErrShareCodeExpired):
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.share_code_expired"),
})
return
// Residence errors - 404 Not Found (share code)
case errors.Is(err, services.ErrShareCodeInvalid):
c.JSON(http.StatusNotFound, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.share_code_invalid"),
})
return
// Residence errors - 409 Conflict
case errors.Is(err, services.ErrUserAlreadyMember):
c.JSON(http.StatusConflict, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.user_already_member"),
})
return
}
// Default: Internal server error (don't expose error details to client)
log.Error().Err(err).Msg("Unhandled error")
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
Error: i18n.LocalizedMessage(c, "error.internal"),
})
}

View File

@@ -11,6 +11,7 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
@@ -18,18 +19,20 @@ import (
"github.com/treytartt/casera-api/internal/repositories"
)
// Deprecated: Legacy error constants - kept for reference during transition
// Use apperrors package instead
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUsernameTaken = errors.New("username already taken")
ErrEmailTaken = errors.New("email already taken")
ErrUserInactive = errors.New("user account is inactive")
ErrInvalidCode = errors.New("invalid verification code")
ErrCodeExpired = errors.New("verification code expired")
ErrAlreadyVerified = errors.New("email already verified")
ErrRateLimitExceeded = errors.New("too many requests, please try again later")
ErrInvalidResetToken = errors.New("invalid or expired reset token")
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
// ErrInvalidCredentials = errors.New("invalid credentials")
// ErrUsernameTaken = errors.New("username already taken")
// ErrEmailTaken = errors.New("email already taken")
// ErrUserInactive = errors.New("user account is inactive")
// ErrInvalidCode = errors.New("invalid verification code")
// ErrCodeExpired = errors.New("verification code expired")
// ErrAlreadyVerified = errors.New("email already verified")
// ErrRateLimitExceeded = errors.New("too many requests, please try again later")
// ErrInvalidResetToken = errors.New("invalid or expired reset token")
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
ErrGoogleSignInFailed = errors.New("Google Sign In failed")
)
// AuthService handles authentication business logic
@@ -63,25 +66,25 @@ func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginRespons
user, err := s.userRepo.FindByUsernameOrEmail(identifier)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) {
return nil, ErrInvalidCredentials
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
return nil, fmt.Errorf("failed to find user: %w", err)
return nil, apperrors.Internal(err)
}
// Check if user is active
if !user.IsActive {
return nil, ErrUserInactive
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Verify password
if !user.CheckPassword(req.Password) {
return nil, ErrInvalidCredentials
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
// Get or create auth token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Update last login
@@ -101,19 +104,19 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
// Check if username exists
exists, err := s.userRepo.ExistsByUsername(req.Username)
if err != nil {
return nil, "", fmt.Errorf("failed to check username: %w", err)
return nil, "", apperrors.Internal(err)
}
if exists {
return nil, "", ErrUsernameTaken
return nil, "", apperrors.Conflict("error.username_taken")
}
// Check if email exists
exists, err = s.userRepo.ExistsByEmail(req.Email)
if err != nil {
return nil, "", fmt.Errorf("failed to check email: %w", err)
return nil, "", apperrors.Internal(err)
}
if exists {
return nil, "", ErrEmailTaken
return nil, "", apperrors.Conflict("error.email_taken")
}
// Create user
@@ -127,12 +130,12 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
// Hash password
if err := user.SetPassword(req.Password); err != nil {
return nil, "", fmt.Errorf("failed to hash password: %w", err)
return nil, "", apperrors.Internal(err)
}
// Save user
if err := s.userRepo.Create(user); err != nil {
return nil, "", fmt.Errorf("failed to create user: %w", err)
return nil, "", apperrors.Internal(err)
}
// Create user profile
@@ -152,7 +155,7 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist
// Create auth token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, "", fmt.Errorf("failed to create token: %w", err)
return nil, "", apperrors.Internal(err)
}
// Generate confirmation code - use fixed code in debug mode for easier local testing
@@ -203,10 +206,10 @@ func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequ
if req.Email != nil && *req.Email != user.Email {
exists, err := s.userRepo.ExistsByEmail(*req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check email: %w", err)
return nil, apperrors.Internal(err)
}
if exists {
return nil, ErrEmailTaken
return nil, apperrors.Conflict("error.email_already_taken")
}
user.Email = *req.Email
}
@@ -219,7 +222,7 @@ func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequ
}
if err := s.userRepo.Update(user); err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
return nil, apperrors.Internal(err)
}
// Reload with profile
@@ -237,18 +240,18 @@ func (s *AuthService) VerifyEmail(userID uint, code string) error {
// Get user profile
profile, err := s.userRepo.GetOrCreateProfile(userID)
if err != nil {
return fmt.Errorf("failed to get profile: %w", err)
return apperrors.Internal(err)
}
// Check if already verified
if profile.Verified {
return ErrAlreadyVerified
return apperrors.BadRequest("error.email_already_verified")
}
// Check for test code in debug mode
if s.cfg.Server.Debug && code == "123456" {
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
return fmt.Errorf("failed to verify profile: %w", err)
return apperrors.Internal(err)
}
return nil
}
@@ -257,22 +260,22 @@ func (s *AuthService) VerifyEmail(userID uint, code string) error {
confirmCode, err := s.userRepo.FindConfirmationCode(userID, code)
if err != nil {
if errors.Is(err, repositories.ErrCodeNotFound) {
return ErrInvalidCode
return apperrors.BadRequest("error.invalid_verification_code")
}
if errors.Is(err, repositories.ErrCodeExpired) {
return ErrCodeExpired
return apperrors.BadRequest("error.verification_code_expired")
}
return err
return apperrors.Internal(err)
}
// Mark code as used
if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
return fmt.Errorf("failed to mark code as used: %w", err)
return apperrors.Internal(err)
}
// Set profile as verified
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
return fmt.Errorf("failed to verify profile: %w", err)
return apperrors.Internal(err)
}
return nil
@@ -283,12 +286,12 @@ func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
// Get user profile
profile, err := s.userRepo.GetOrCreateProfile(userID)
if err != nil {
return "", fmt.Errorf("failed to get profile: %w", err)
return "", apperrors.Internal(err)
}
// Check if already verified
if profile.Verified {
return "", ErrAlreadyVerified
return "", apperrors.BadRequest("error.email_already_verified")
}
// Generate new code - use fixed code in debug mode for easier local testing
@@ -301,7 +304,7 @@ func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil {
return "", fmt.Errorf("failed to create confirmation code: %w", err)
return "", apperrors.Internal(err)
}
return code, nil
@@ -322,10 +325,10 @@ func (s *AuthService) ForgotPassword(email string) (string, *models.User, error)
// Check rate limit
count, err := s.userRepo.CountRecentPasswordResetRequests(user.ID)
if err != nil {
return "", nil, fmt.Errorf("failed to check rate limit: %w", err)
return "", nil, apperrors.Internal(err)
}
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
return "", nil, ErrRateLimitExceeded
return "", nil, apperrors.TooManyRequests("error.rate_limit_exceeded")
}
// Generate code and reset token - use fixed code in debug mode for easier local testing
@@ -341,11 +344,11 @@ func (s *AuthService) ForgotPassword(email string) (string, *models.User, error)
// Hash the code before storing
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return "", nil, fmt.Errorf("failed to hash code: %w", err)
return "", nil, apperrors.Internal(err)
}
if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
return "", nil, fmt.Errorf("failed to create reset code: %w", err)
return "", nil, apperrors.Internal(err)
}
return code, user, nil
@@ -357,9 +360,9 @@ func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
resetCode, user, err := s.userRepo.FindPasswordResetCodeByEmail(email)
if err != nil {
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
return "", ErrInvalidCode
return "", apperrors.BadRequest("error.invalid_verification_code")
}
return "", err
return "", apperrors.Internal(err)
}
// Check for test code in debug mode
@@ -371,18 +374,18 @@ func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
if !resetCode.CheckCode(code) {
// Increment attempts
s.userRepo.IncrementResetCodeAttempts(resetCode.ID)
return "", ErrInvalidCode
return "", apperrors.BadRequest("error.invalid_verification_code")
}
// Check if code is still valid
if !resetCode.IsValid() {
if resetCode.Used {
return "", ErrInvalidCode
return "", apperrors.BadRequest("error.invalid_verification_code")
}
if resetCode.Attempts >= resetCode.MaxAttempts {
return "", ErrRateLimitExceeded
return "", apperrors.TooManyRequests("error.rate_limit_exceeded")
}
return "", ErrCodeExpired
return "", apperrors.BadRequest("error.verification_code_expired")
}
_ = user // user available if needed
@@ -396,24 +399,24 @@ func (s *AuthService) ResetPassword(resetToken, newPassword string) error {
resetCode, err := s.userRepo.FindPasswordResetCodeByToken(resetToken)
if err != nil {
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
return ErrInvalidResetToken
return apperrors.BadRequest("error.invalid_reset_token")
}
return err
return apperrors.Internal(err)
}
// Get the user
user, err := s.userRepo.FindByID(resetCode.UserID)
if err != nil {
return fmt.Errorf("failed to find user: %w", err)
return apperrors.Internal(err)
}
// Update password
if err := user.SetPassword(newPassword); err != nil {
return fmt.Errorf("failed to hash password: %w", err)
return apperrors.Internal(err)
}
if err := s.userRepo.Update(user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
return apperrors.Internal(err)
}
// Mark reset code as used
@@ -436,7 +439,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
// 1. Verify the Apple JWT token
claims, err := appleAuth.VerifyIdentityToken(ctx, req.IDToken)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrAppleSignInFailed, err)
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
}
// Use the subject from claims as the authoritative Apple ID
@@ -451,17 +454,17 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
// User already linked with this Apple ID - log them in
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
return nil, apperrors.Internal(err)
}
if !user.IsActive {
return nil, ErrUserInactive
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Get or create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Update last login
@@ -487,7 +490,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
}
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to link Apple ID: %w", err)
return nil, apperrors.Internal(err)
}
// Mark as verified since Apple verified the email
@@ -496,7 +499,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
// Get or create token
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Update last login
@@ -529,7 +532,7 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
_ = user.SetPassword(randomPassword)
if err := s.userRepo.Create(user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
return nil, apperrors.Internal(err)
}
// Create profile (already verified since Apple verified)
@@ -554,13 +557,13 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
}
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to create Apple auth: %w", err)
return nil, apperrors.Internal(err)
}
// Create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Reload user with profile
@@ -578,12 +581,12 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
// 1. Verify the Google ID token
tokenInfo, err := googleAuth.VerifyIDToken(ctx, req.IDToken)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrGoogleSignInFailed, err)
return nil, apperrors.Unauthorized("error.invalid_credentials").Wrap(err)
}
googleID := tokenInfo.Sub
if googleID == "" {
return nil, fmt.Errorf("%w: missing subject claim", ErrGoogleSignInFailed)
return nil, apperrors.Unauthorized("error.invalid_credentials")
}
// 2. Check if this Google ID is already linked to an account
@@ -592,17 +595,17 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
// User already linked with this Google ID - log them in
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
return nil, apperrors.Internal(err)
}
if !user.IsActive {
return nil, ErrUserInactive
return nil, apperrors.Unauthorized("error.account_inactive")
}
// Get or create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Update last login
@@ -629,7 +632,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
Picture: tokenInfo.Picture,
}
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to link Google ID: %w", err)
return nil, apperrors.Internal(err)
}
// Mark as verified since Google verified the email
@@ -640,7 +643,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
// Get or create token
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Update last login
@@ -673,7 +676,7 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
_ = user.SetPassword(randomPassword)
if err := s.userRepo.Create(user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
return nil, apperrors.Internal(err)
}
// Create profile (already verified if Google verified email)
@@ -699,13 +702,13 @@ func (s *AuthService) GoogleSignIn(ctx context.Context, googleAuth *GoogleAuthSe
Picture: tokenInfo.Picture,
}
if err := s.userRepo.CreateGoogleSocialAuth(googleAuthRecord); err != nil {
return nil, fmt.Errorf("failed to create Google auth: %w", err)
return nil, apperrors.Internal(err)
}
// Create token
token, err := s.userRepo.GetOrCreateToken(user.ID)
if err != nil {
return nil, fmt.Errorf("failed to create token: %w", err)
return nil, apperrors.Internal(err)
}
// Reload user with profile

View File

@@ -5,17 +5,18 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"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"
)
// Contractor-related errors
var (
ErrContractorNotFound = errors.New("contractor not found")
ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
)
// Deprecated: Use apperrors.NotFound("error.contractor_not_found") instead
// var (
// ErrContractorNotFound = errors.New("contractor not found")
// ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
// )
// ContractorService handles contractor business logic
type ContractorService struct {
@@ -36,14 +37,14 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
resp := responses.NewContractorResponse(contractor)
@@ -73,13 +74,13 @@ func (s *ContractorService) ListContractors(userID uint) ([]responses.Contractor
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// FindByUser now handles both personal and residence contractors
contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
@@ -91,10 +92,10 @@ func (s *ContractorService) CreateContractor(req *requests.CreateContractorReque
if req.ResidenceID != nil {
hasAccess, err := s.residenceRepo.HasAccess(*req.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
}
@@ -122,20 +123,20 @@ func (s *ContractorService) CreateContractor(req *requests.CreateContractorReque
}
if err := s.contractorRepo.Create(contractor); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Set specialties if provided
if len(req.SpecialtyIDs) > 0 {
if err := s.contractorRepo.SetSpecialties(contractor.ID, req.SpecialtyIDs); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
}
// Reload with relations
contractor, reloadErr := s.contractorRepo.FindByID(contractor.ID)
if reloadErr != nil {
return nil, reloadErr
return nil, apperrors.Internal(reloadErr)
}
resp := responses.NewContractorResponse(contractor)
@@ -147,14 +148,14 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
// Apply updates
@@ -199,20 +200,20 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req
contractor.ResidenceID = req.ResidenceID
if err := s.contractorRepo.Update(contractor); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Update specialties if provided
if req.SpecialtyIDs != nil {
if err := s.contractorRepo.SetSpecialties(contractorID, req.SpecialtyIDs); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
}
// Reload
contractor, err = s.contractorRepo.FindByID(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
@@ -224,17 +225,21 @@ func (s *ContractorService) DeleteContractor(contractorID, userID uint) error {
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrContractorNotFound
return apperrors.NotFound("error.contractor_not_found")
}
return err
return apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return ErrContractorAccessDenied
return apperrors.Forbidden("error.contractor_access_denied")
}
return s.contractorRepo.Delete(contractorID)
if err := s.contractorRepo.Delete(contractorID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor
@@ -242,25 +247,25 @@ func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*response
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
_, err = s.contractorRepo.ToggleFavorite(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Re-fetch the contractor to get the updated state with all relations
contractor, err = s.contractorRepo.FindByID(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
@@ -272,19 +277,19 @@ func (s *ContractorService) GetContractorTasks(contractorID, userID uint) ([]res
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
tasks, err := s.contractorRepo.GetTasksForContractor(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewTaskListResponse(tasks), nil
@@ -295,15 +300,15 @@ func (s *ContractorService) ListContractorsByResidence(residenceID, userID uint)
// Check user has access to the residence
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
contractors, err := s.contractorRepo.FindByResidence(residenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
@@ -313,7 +318,7 @@ func (s *ContractorService) ListContractorsByResidence(residenceID, userID uint)
func (s *ContractorService) GetSpecialties() ([]responses.ContractorSpecialtyResponse, error) {
specialties, err := s.contractorRepo.GetAllSpecialties()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.ContractorSpecialtyResponse, len(specialties))

View File

@@ -5,6 +5,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/models"
@@ -12,10 +13,11 @@ import (
)
// Document-related errors
var (
ErrDocumentNotFound = errors.New("document not found")
ErrDocumentAccessDenied = errors.New("you do not have access to this document")
)
// DEPRECATED: These constants are deprecated. Use apperrors package instead.
// var (
// ErrDocumentNotFound = errors.New("document not found")
// ErrDocumentAccessDenied = errors.New("you do not have access to this document")
// )
// DocumentService handles document business logic
type DocumentService struct {
@@ -36,18 +38,18 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum
document, err := s.documentRepo.FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrDocumentNotFound
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrDocumentAccessDenied
return nil, apperrors.Forbidden("error.document_access_denied")
}
resp := responses.NewDocumentResponse(document)
@@ -59,7 +61,7 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
@@ -68,7 +70,7 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon
documents, err := s.documentRepo.FindByUser(residenceIDs)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewDocumentListResponse(documents), nil
@@ -79,7 +81,7 @@ func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentRespo
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
@@ -88,7 +90,7 @@ func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentRespo
documents, err := s.documentRepo.FindWarranties(residenceIDs)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewDocumentListResponse(documents), nil
@@ -99,10 +101,10 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
// Check residence access
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
documentType := req.DocumentType
@@ -131,7 +133,7 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
}
if err := s.documentRepo.Create(document); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Create images if provided
@@ -151,7 +153,7 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
// Reload with relations
document, err = s.documentRepo.FindByID(document.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
@@ -163,18 +165,18 @@ func (s *DocumentService) UpdateDocument(documentID, userID uint, req *requests.
document, err := s.documentRepo.FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrDocumentNotFound
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrDocumentAccessDenied
return nil, apperrors.Forbidden("error.document_access_denied")
}
// Apply updates
@@ -222,13 +224,13 @@ func (s *DocumentService) UpdateDocument(documentID, userID uint, req *requests.
}
if err := s.documentRepo.Update(document); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
document, err = s.documentRepo.FindByID(documentID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(document)
@@ -240,21 +242,25 @@ func (s *DocumentService) DeleteDocument(documentID, userID uint) error {
document, err := s.documentRepo.FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrDocumentNotFound
return apperrors.NotFound("error.document_not_found")
}
return err
return apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
if err != nil {
return err
return apperrors.Internal(err)
}
if !hasAccess {
return ErrDocumentAccessDenied
return apperrors.Forbidden("error.document_access_denied")
}
return s.documentRepo.Delete(documentID)
if err := s.documentRepo.Delete(documentID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ActivateDocument activates a document
@@ -262,26 +268,26 @@ func (s *DocumentService) ActivateDocument(documentID, userID uint) (*responses.
// First check if document exists (even if inactive)
var document models.Document
if err := s.documentRepo.FindByIDIncludingInactive(documentID, &document); err != nil {
return nil, ErrDocumentNotFound
return nil, apperrors.NotFound("error.document_not_found")
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrDocumentAccessDenied
return nil, apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.Activate(documentID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
doc, err := s.documentRepo.FindByID(documentID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewDocumentResponse(doc)
@@ -293,22 +299,22 @@ func (s *DocumentService) DeactivateDocument(documentID, userID uint) (*response
document, err := s.documentRepo.FindByID(documentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrDocumentNotFound
return nil, apperrors.NotFound("error.document_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrDocumentAccessDenied
return nil, apperrors.Forbidden("error.document_access_denied")
}
if err := s.documentRepo.Deactivate(documentID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
document.IsActive = false

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
@@ -15,8 +16,11 @@ import (
// Notification-related errors
var (
// Deprecated: Use apperrors.NotFound("error.notification_not_found") instead
ErrNotificationNotFound = errors.New("notification not found")
// Deprecated: Use apperrors.NotFound("error.device_not_found") instead
ErrDeviceNotFound = errors.New("device not found")
// Deprecated: Use apperrors.BadRequest("error.invalid_platform") instead
ErrInvalidPlatform = errors.New("invalid platform, must be 'ios' or 'android'")
)
@@ -40,7 +44,7 @@ func NewNotificationService(notificationRepo *repositories.NotificationRepositor
func (s *NotificationService) GetNotifications(userID uint, limit, offset int) ([]NotificationResponse, error) {
notifications, err := s.notificationRepo.FindByUser(userID, limit, offset)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]NotificationResponse, len(notifications))
@@ -52,7 +56,11 @@ func (s *NotificationService) GetNotifications(userID uint, limit, offset int) (
// GetUnreadCount gets the count of unread notifications
func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) {
return s.notificationRepo.CountUnread(userID)
count, err := s.notificationRepo.CountUnread(userID)
if err != nil {
return 0, apperrors.Internal(err)
}
return count, nil
}
// MarkAsRead marks a notification as read
@@ -60,21 +68,27 @@ func (s *NotificationService) MarkAsRead(notificationID, userID uint) error {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotificationNotFound
return apperrors.NotFound("error.notification_not_found")
}
return err
return apperrors.Internal(err)
}
if notification.UserID != userID {
return ErrNotificationNotFound
return apperrors.NotFound("error.notification_not_found")
}
return s.notificationRepo.MarkAsRead(notificationID)
if err := s.notificationRepo.MarkAsRead(notificationID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// MarkAllAsRead marks all notifications as read
func (s *NotificationService) MarkAllAsRead(userID uint) error {
return s.notificationRepo.MarkAllAsRead(userID)
if err := s.notificationRepo.MarkAllAsRead(userID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// CreateAndSendNotification creates a notification and sends it via push
@@ -82,7 +96,7 @@ func (s *NotificationService) CreateAndSendNotification(ctx context.Context, use
// Check user preferences
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
// Check if notification type is enabled
@@ -101,13 +115,13 @@ func (s *NotificationService) CreateAndSendNotification(ctx context.Context, use
}
if err := s.notificationRepo.Create(notification); err != nil {
return err
return apperrors.Internal(err)
}
// Get device tokens
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
// Convert data for push
@@ -128,11 +142,14 @@ func (s *NotificationService) CreateAndSendNotification(ctx context.Context, use
err = s.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, body, pushData)
if err != nil {
s.notificationRepo.SetError(notification.ID, err.Error())
return err
return apperrors.Internal(err)
}
}
return s.notificationRepo.MarkAsSent(notification.ID)
if err := s.notificationRepo.MarkAsSent(notification.ID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// isNotificationEnabled checks if a notification type is enabled for user
@@ -161,7 +178,7 @@ func (s *NotificationService) isNotificationEnabled(prefs *models.NotificationPr
func (s *NotificationService) GetPreferences(userID uint) (*NotificationPreferencesResponse, error) {
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewNotificationPreferencesResponse(prefs), nil
}
@@ -170,7 +187,7 @@ func (s *NotificationService) GetPreferences(userID uint) (*NotificationPreferen
func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) {
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if req.TaskDueSoon != nil {
@@ -214,7 +231,7 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
}
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewNotificationPreferencesResponse(prefs), nil
@@ -230,7 +247,7 @@ func (s *NotificationService) RegisterDevice(userID uint, req *RegisterDeviceReq
case push.PlatformAndroid:
return s.registerGCMDevice(userID, req)
default:
return nil, ErrInvalidPlatform
return nil, apperrors.BadRequest("error.invalid_platform")
}
}
@@ -244,7 +261,7 @@ func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDevic
existing.Name = req.Name
existing.DeviceID = req.DeviceID
if err := s.notificationRepo.UpdateAPNSDevice(existing); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewAPNSDeviceResponse(existing), nil
}
@@ -258,7 +275,7 @@ func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDevic
Active: true,
}
if err := s.notificationRepo.CreateAPNSDevice(device); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewAPNSDeviceResponse(device), nil
}
@@ -273,7 +290,7 @@ func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDevice
existing.Name = req.Name
existing.DeviceID = req.DeviceID
if err := s.notificationRepo.UpdateGCMDevice(existing); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewGCMDeviceResponse(existing), nil
}
@@ -288,7 +305,7 @@ func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDevice
Active: true,
}
if err := s.notificationRepo.CreateGCMDevice(device); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewGCMDeviceResponse(device), nil
}
@@ -297,12 +314,12 @@ func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDevice
func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error) {
iosDevices, err := s.notificationRepo.FindAPNSDevicesByUser(userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
return nil, apperrors.Internal(err)
}
androidDevices, err := s.notificationRepo.FindGCMDevicesByUser(userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]DeviceResponse, 0, len(iosDevices)+len(androidDevices))
@@ -317,14 +334,19 @@ func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error)
// DeleteDevice deletes a device
func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userID uint) error {
var err error
switch platform {
case push.PlatformIOS:
return s.notificationRepo.DeactivateAPNSDevice(deviceID)
err = s.notificationRepo.DeactivateAPNSDevice(deviceID)
case push.PlatformAndroid:
return s.notificationRepo.DeactivateGCMDevice(deviceID)
err = s.notificationRepo.DeactivateGCMDevice(deviceID)
default:
return ErrInvalidPlatform
return apperrors.BadRequest("error.invalid_platform")
}
if err != nil {
return apperrors.Internal(err)
}
return nil
}
// === Response/Request Types ===
@@ -490,7 +512,7 @@ func (s *NotificationService) CreateAndSendTaskNotification(
// Check user notification preferences
prefs, err := s.notificationRepo.GetOrCreatePreferences(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
if !s.isNotificationEnabled(prefs, notificationType) {
return nil // Skip silently
@@ -527,13 +549,13 @@ func (s *NotificationService) CreateAndSendTaskNotification(
}
if err := s.notificationRepo.Create(notification); err != nil {
return err
return apperrors.Internal(err)
}
// Get device tokens
iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
// Convert data for push payload
@@ -556,9 +578,12 @@ func (s *NotificationService) CreateAndSendTaskNotification(
err = s.pushClient.SendActionableNotification(ctx, iosTokens, androidTokens, title, body, pushData, iosCategoryID)
if err != nil {
s.notificationRepo.SetError(notification.ID, err.Error())
return err
return apperrors.Internal(err)
}
}
return s.notificationRepo.MarkAsSent(notification.ID)
if err := s.notificationRepo.MarkAsSent(notification.ID); err != nil {
return apperrors.Internal(err)
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
@@ -14,15 +15,17 @@ import (
"github.com/treytartt/casera-api/internal/task/predicates"
)
// Common errors
// 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")
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")
)
@@ -53,18 +56,18 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrResidenceNotFound
return nil, apperrors.NotFound("error.residence_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewResidenceResponse(residence)
@@ -75,7 +78,7 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewResidenceListResponse(residences), nil
@@ -84,38 +87,31 @@ func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceRes
// 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 now calculated client-side
// from kanban data for performance. Only TotalResidences and per-residence OverdueCount
// are returned from the server.
func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*responses.MyResidencesResponse, error) {
residences, err := s.residenceRepo.FindByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
residenceResponses := responses.NewResidenceListResponse(residences)
// Build summary with real task statistics
// Summary statistics (TotalTasks, TotalOverdue, etc.) are calculated client-side
// from kanban data. We only populate TotalResidences here.
summary := responses.TotalSummary{
TotalResidences: len(residences),
}
// Get task statistics if task repository is available
// Get per-residence overdue counts for residence card badges
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 using user's timezone-aware time
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
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
}
// Get per-residence overdue counts using user's timezone-aware time
overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now)
if err == nil && overdueCounts != nil {
for i := range residenceResponses {
@@ -134,32 +130,22 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
// 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.
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
//
// 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(userID uint, now time.Time) (*responses.TotalSummary, error) {
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
summary := &responses.TotalSummary{
// Summary statistics are calculated client-side from kanban data.
// We only return TotalResidences here.
return &responses.TotalSummary{
TotalResidences: len(residenceIDs),
}
// Get task statistics if task repository is available
if s.taskRepo != nil && len(residenceIDs) > 0 {
// Get aggregated statistics using user's timezone-aware time
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
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 summary, nil
}, nil
}
// getSummaryForUser returns an empty summary placeholder.
@@ -215,13 +201,13 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest,
}
if err := s.residenceRepo.Create(residence); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload with relations
residence, err := s.residenceRepo.FindByID(residence.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get updated summary
@@ -238,18 +224,18 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !isOwner {
return nil, ErrNotResidenceOwner
return nil, apperrors.Forbidden("error.not_residence_owner")
}
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrResidenceNotFound
return nil, apperrors.NotFound("error.residence_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Apply updates (only non-nil fields)
@@ -306,13 +292,13 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
}
if err := s.residenceRepo.Update(residence); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload with relations
residence, err = s.residenceRepo.FindByID(residence.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get updated summary
@@ -329,14 +315,14 @@ func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !isOwner {
return nil, ErrNotResidenceOwner
return nil, apperrors.Forbidden("error.not_residence_owner")
}
if err := s.residenceRepo.Delete(residenceID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get updated summary
@@ -353,10 +339,10 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !isOwner {
return nil, ErrNotResidenceOwner
return nil, apperrors.Forbidden("error.not_residence_owner")
}
// Default to 24 hours if not specified
@@ -366,7 +352,7 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.GenerateShareCodeResponse{
@@ -380,22 +366,22 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
// Check ownership (only owners can share residences)
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !isOwner {
return nil, ErrNotResidenceOwner
return nil, apperrors.Forbidden("error.not_residence_owner")
}
// Get residence details for the package
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get the user who's sharing
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Default to 24 hours if not specified
@@ -406,7 +392,7 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
// Generate the share code
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.SharePackageResponse{
@@ -423,23 +409,23 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
shareCode, err := s.residenceRepo.FindShareCodeByCode(code)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrShareCodeInvalid
return nil, apperrors.NotFound("error.share_code_invalid")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check if already a member
hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if hasAccess {
return nil, ErrUserAlreadyMember
return nil, apperrors.Conflict("error.user_already_member")
}
// Add user to residence
if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Mark share code as used (one-time use)
@@ -451,7 +437,7 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
// Get the residence with full details
residence, err := s.residenceRepo.FindByID(shareCode.ResidenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get updated summary for the user
@@ -469,15 +455,15 @@ func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]respon
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
users, err := s.residenceRepo.GetResidenceUsers(residenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.ResidenceUserResponse, len(users))
@@ -493,39 +479,43 @@ func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUse
// Check ownership
isOwner, err := s.residenceRepo.IsOwner(residenceID, requestingUserID)
if err != nil {
return err
return apperrors.Internal(err)
}
if !isOwner {
return ErrNotResidenceOwner
return apperrors.Forbidden("error.not_residence_owner")
}
// Cannot remove the owner
if userIDToRemove == requestingUserID {
return ErrCannotRemoveOwner
return apperrors.BadRequest("error.cannot_remove_owner")
}
// Check if the residence exists
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrResidenceNotFound
return apperrors.NotFound("error.residence_not_found")
}
return err
return apperrors.Internal(err)
}
// Cannot remove the owner
if userIDToRemove == residence.OwnerID {
return ErrCannotRemoveOwner
return apperrors.BadRequest("error.cannot_remove_owner")
}
return s.residenceRepo.RemoveUser(residenceID, userIDToRemove)
if err := s.residenceRepo.RemoveUser(residenceID, userIDToRemove); err != nil {
return apperrors.Internal(err)
}
return nil
}
// GetResidenceTypes returns all residence types
func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) {
types, err := s.residenceRepo.GetAllResidenceTypes()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.ResidenceTypeResponse, len(types))
@@ -567,22 +557,25 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
// Get residence details
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
if err != nil {
return nil, ErrResidenceNotFound
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.GetTasksForReport(residenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
now := time.Now().UTC()

View File

@@ -1,6 +1,7 @@
package services
import (
"net/http"
"testing"
"github.com/shopspring/decimal"
@@ -115,7 +116,7 @@ func TestResidenceService_GetResidence_AccessDenied(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
_, err := service.GetResidence(residence.ID, otherUser.ID)
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
func TestResidenceService_GetResidence_NotFound(t *testing.T) {
@@ -188,7 +189,7 @@ func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) {
req := &requests.UpdateResidenceRequest{Name: &newName}
_, err := service.UpdateResidence(residence.ID, sharedUser.ID, req)
assert.ErrorIs(t, err, ErrNotResidenceOwner)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner")
}
func TestResidenceService_DeleteResidence(t *testing.T) {
@@ -222,7 +223,7 @@ func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) {
residenceRepo.AddUser(residence.ID, sharedUser.ID)
_, err := service.DeleteResidence(residence.ID, sharedUser.ID)
assert.ErrorIs(t, err, ErrNotResidenceOwner)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.not_residence_owner")
}
func TestResidenceService_GenerateShareCode(t *testing.T) {
@@ -280,7 +281,7 @@ func TestResidenceService_JoinWithCode_AlreadyMember(t *testing.T) {
// Owner tries to join their own residence
_, err := service.JoinWithCode(shareResp.ShareCode.Code, owner.ID)
assert.ErrorIs(t, err, ErrUserAlreadyMember)
testutil.AssertAppError(t, err, http.StatusConflict, "error.user_already_member")
}
func TestResidenceService_GetResidenceUsers(t *testing.T) {
@@ -330,5 +331,5 @@ func TestResidenceService_RemoveUser_CannotRemoveOwner(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
err := service.RemoveUser(residence.ID, owner.ID, owner.ID)
assert.ErrorIs(t, err, ErrCannotRemoveOwner)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.cannot_remove_owner")
}

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
@@ -15,12 +16,19 @@ import (
// Subscription-related errors
var (
// Deprecated: Use apperrors.NotFound("error.subscription_not_found") instead
ErrSubscriptionNotFound = errors.New("subscription not found")
// Deprecated: Use apperrors.Forbidden("error.properties_limit_exceeded") instead
ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.tasks_limit_exceeded") instead
ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.contractors_limit_exceeded") instead
ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.documents_limit_exceeded") instead
ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier")
// Deprecated: Use apperrors.NotFound("error.upgrade_trigger_not_found") instead
ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found")
// Deprecated: Use apperrors.NotFound("error.promotion_not_found") instead
ErrPromotionNotFound = errors.New("promotion not found")
)
@@ -93,7 +101,7 @@ func NewSubscriptionService(
func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewSubscriptionResponse(sub), nil
}
@@ -102,18 +110,18 @@ func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionRespons
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
settings, err := s.subscriptionRepo.GetSettings()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get all tier limits and build a map
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
limitsMap := make(map[string]*TierLimitsClientResponse)
@@ -169,7 +177,7 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
residences, err := s.residenceRepo.FindOwnedByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
propertiesCount := int64(len(residences))
@@ -178,19 +186,19 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
for _, r := range residences {
tc, err := s.taskRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
tasksCount += tc
cc, err := s.contractorRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
contractorsCount += cc
dc, err := s.documentRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
documentsCount += dc
}
@@ -207,7 +215,7 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
settings, err := s.subscriptionRepo.GetSettings()
if err != nil {
return err
return apperrors.Internal(err)
}
// If limitations are disabled globally, allow everything
@@ -217,7 +225,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
// IsFree users bypass all limitations
@@ -232,7 +240,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
if err != nil {
return err
return apperrors.Internal(err)
}
usage, err := s.getUserUsage(userID)
@@ -243,19 +251,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
switch limitType {
case "properties":
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
return ErrPropertiesLimitExceeded
return apperrors.Forbidden("error.properties_limit_exceeded")
}
case "tasks":
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
return ErrTasksLimitExceeded
return apperrors.Forbidden("error.tasks_limit_exceeded")
}
case "contractors":
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
return ErrContractorsLimitExceeded
return apperrors.Forbidden("error.contractors_limit_exceeded")
}
case "documents":
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
return ErrDocumentsLimitExceeded
return apperrors.Forbidden("error.documents_limit_exceeded")
}
}
@@ -267,9 +275,9 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUpgradeTriggerNotFound
return nil, apperrors.NotFound("error.upgrade_trigger_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
return NewUpgradeTriggerResponse(trigger), nil
}
@@ -279,7 +287,7 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make(map[string]*UpgradeTriggerDataResponse)
@@ -293,7 +301,7 @@ func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTrigge
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]FeatureBenefitResponse, len(benefits))
@@ -307,12 +315,12 @@ func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, er
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]PromotionResponse, len(promotions))
@@ -331,7 +339,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
dataToStore = transactionID
}
if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Validate with Apple if client is configured
@@ -375,7 +383,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
@@ -386,7 +394,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
// Store purchase token first
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Validate the purchase with Google if client is configured
@@ -443,7 +451,7 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
@@ -452,7 +460,7 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
// CancelSubscription cancels a subscription (downgrades to free at end of period)
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
}

View File

@@ -869,8 +869,12 @@ func TestEdgeCase_TaskDueExactlyAtThreshold(t *testing.T) {
}
func TestEdgeCase_TaskDueJustBeforeThreshold(t *testing.T) {
// 29 days and 23 hours from now
dueDate := time.Now().UTC().Add(29*24*time.Hour + 23*time.Hour)
// Task due 29 days from today's start of day should be "due_soon"
// (within the 30-day threshold)
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
dueDate := startOfToday.AddDate(0, 0, 29) // 29 days from start of today
task := &models.Task{
NextDueDate: &dueDate,
}
@@ -878,7 +882,7 @@ func TestEdgeCase_TaskDueJustBeforeThreshold(t *testing.T) {
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"Task due just before threshold should be in due_soon")
"Task due 29 days from today should be in due_soon (within 30-day threshold)")
}
func TestEdgeCase_TaskDueInPast_ButHasCompletionAfter(t *testing.T) {
@@ -955,20 +959,23 @@ func TestEdgeCase_MonthlyRecurringTask(t *testing.T) {
CompletedAt: completedAt,
})
// Update NextDueDate
// Update NextDueDate - set to 29 days from today (within 30-day threshold)
task, _ = taskRepo.FindByID(task.ID)
nextDue := completedAt.AddDate(0, 0, 30) // 30 days from now
now := time.Now().UTC()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
nextDue := startOfToday.AddDate(0, 0, 29) // 29 days from start of today (within threshold)
task.NextDueDate = &nextDue
db.Save(task)
task, _ = taskRepo.FindByID(task.ID)
// 30 days from now is at/within threshold boundary - due to time precision,
// a task at exactly the threshold boundary is considered "due_soon" not "upcoming"
// because the check is NextDueDate.Before(threshold) which includes boundary due to ms precision
// With day-based comparisons:
// - Threshold = start of today + 30 days
// - A task due on day 29 is Before(threshold), so it's "due_soon"
// - A task due on day 30+ is NOT Before(threshold), so it's "upcoming"
column := responses.DetermineKanbanColumn(task, 30)
assert.Equal(t, "due_soon_tasks", column,
"Monthly task at 30-day boundary should be due_soon (at threshold)")
"Monthly task within 30-day threshold should be due_soon")
}
func TestEdgeCase_ZeroDayFrequency_TreatedAsOneTime(t *testing.T) {

View File

@@ -8,16 +8,18 @@ import (
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"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"
)
// Task-related errors
// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
// TODO: Migrate handlers to use apperrors instead of these constants
var (
ErrTaskNotFound = errors.New("task not found")
ErrTaskAccessDenied = errors.New("you do not have access to this task")
ErrTaskNotFound = errors.New("task not found")
ErrTaskAccessDenied = errors.New("you do not have access to this task")
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
ErrTaskAlreadyArchived = errors.New("task is already archived")
ErrCompletionNotFound = errors.New("task completion not found")
@@ -71,18 +73,18 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
resp := responses.NewTaskResponse(task)
@@ -95,7 +97,7 @@ func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBo
// Get all residence IDs accessible to user (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
@@ -110,7 +112,7 @@ func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBo
// Get kanban data aggregated across all residences using user's timezone-aware time
board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, now)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewKanbanBoardResponseForAll(board)
@@ -126,10 +128,10 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
if daysThreshold <= 0 {
@@ -139,7 +141,7 @@ func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshol
// Get kanban data using user's timezone-aware time
board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold, now)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewKanbanBoardResponse(board, residenceID)
@@ -155,10 +157,10 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, n
// Check residence access
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrResidenceAccessDenied
return nil, apperrors.Forbidden("error.residence_access_denied")
}
dueDate := req.DueDate.ToTimePtr()
@@ -180,13 +182,13 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint, n
}
if err := s.taskRepo.Create(task); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload with relations
task, err = s.taskRepo.FindByID(task.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -201,18 +203,18 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
// Apply updates
@@ -260,13 +262,13 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
}
if err := s.taskRepo.Update(task); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(task.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -280,22 +282,22 @@ func (s *TaskService) DeleteTask(taskID, userID uint) (*responses.DeleteWithSumm
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.Delete(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.DeleteWithSummaryResponse{
@@ -312,28 +314,28 @@ func (s *TaskService) MarkInProgress(taskID, userID uint, now time.Time) (*respo
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.MarkInProgress(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -348,32 +350,32 @@ func (s *TaskService) CancelTask(taskID, userID uint, now time.Time) (*responses
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if task.IsCancelled {
return nil, ErrTaskAlreadyCancelled
return nil, apperrors.BadRequest("error.task_already_cancelled")
}
if err := s.taskRepo.Cancel(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -388,28 +390,28 @@ func (s *TaskService) UncancelTask(taskID, userID uint, now time.Time) (*respons
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.Uncancel(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -424,32 +426,32 @@ func (s *TaskService) ArchiveTask(taskID, userID uint, now time.Time) (*response
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if task.IsArchived {
return nil, ErrTaskAlreadyArchived
return nil, apperrors.BadRequest("error.task_already_archived")
}
if err := s.taskRepo.Archive(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -464,28 +466,28 @@ func (s *TaskService) UnarchiveTask(taskID, userID uint, now time.Time) (*respon
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.Unarchive(taskID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload
task, err = s.taskRepo.FindByID(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.TaskWithSummaryResponse{
@@ -503,18 +505,18 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
task, err := s.taskRepo.FindByID(req.TaskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
completedAt := time.Now().UTC()
@@ -532,7 +534,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
}
if err := s.taskRepo.CreateCompletion(completion); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Update next_due_date and in_progress based on frequency
@@ -589,7 +591,7 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
// Reload completion with user info and images
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Reload task with updated completions (so client can update kanban column)
@@ -622,18 +624,18 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTaskNotFound
return apperrors.NotFound("error.task_not_found")
}
return err
return apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return err
return apperrors.Internal(err)
}
if !hasAccess {
return ErrTaskAccessDenied
return apperrors.Forbidden("error.task_access_denied")
}
completedAt := time.Now().UTC()
@@ -646,7 +648,7 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
}
if err := s.taskRepo.CreateCompletion(completion); err != nil {
return err
return apperrors.Internal(err)
}
// Update next_due_date and in_progress based on frequency
@@ -692,7 +694,7 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after quick completion")
return err // Return error so caller knows the update failed
return apperrors.Internal(err) // Return error so caller knows the update failed
}
log.Info().Uint("task_id", task.ID).Msg("QuickComplete: Task updated successfully")
@@ -771,18 +773,18 @@ func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskC
completion, err := s.taskRepo.FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCompletionNotFound
return nil, apperrors.NotFound("error.completion_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access via task's residence
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
resp := responses.NewTaskCompletionResponse(completion)
@@ -794,7 +796,7 @@ func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionRe
// Get all residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if len(residenceIDs) == 0 {
@@ -803,7 +805,7 @@ func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionRe
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewTaskCompletionListResponse(completions), nil
@@ -814,22 +816,22 @@ func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.De
completion, err := s.taskRepo.FindCompletionByID(completionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrCompletionNotFound
return nil, apperrors.NotFound("error.completion_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
if err := s.taskRepo.DeleteCompletion(completionID); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return &responses.DeleteWithSummaryResponse{
@@ -844,24 +846,24 @@ func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.Tas
task, err := s.taskRepo.FindByID(taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTaskNotFound
return nil, apperrors.NotFound("error.task_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access via residence
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, ErrTaskAccessDenied
return nil, apperrors.Forbidden("error.task_access_denied")
}
// Get completions for the task
completions, err := s.taskRepo.FindCompletionsByTask(taskID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewTaskCompletionListResponse(completions), nil
@@ -873,7 +875,7 @@ func (s *TaskService) GetCompletionsByTask(taskID, userID uint) ([]responses.Tas
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
categories, err := s.taskRepo.GetAllCategories()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskCategoryResponse, len(categories))
@@ -887,7 +889,7 @@ func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error)
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
priorities, err := s.taskRepo.GetAllPriorities()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskPriorityResponse, len(priorities))
@@ -901,7 +903,7 @@ func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error)
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
frequencies, err := s.taskRepo.GetAllFrequencies()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.TaskFrequencyResponse, len(frequencies))

View File

@@ -1,6 +1,7 @@
package services
import (
"net/http"
"testing"
"time"
@@ -105,7 +106,7 @@ func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
now := time.Now().UTC()
_, err := service.CreateTask(req, otherUser.ID, now)
// When creating a task, residence access is checked first
assert.ErrorIs(t, err, ErrResidenceAccessDenied)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
}
func TestTaskService_GetTask(t *testing.T) {
@@ -138,7 +139,7 @@ func TestTaskService_GetTask_AccessDenied(t *testing.T) {
task := testutil.CreateTestTask(t, db, residence.ID, owner.ID, "Test Task")
_, err := service.GetTask(task.ID, otherUser.ID)
assert.ErrorIs(t, err, ErrTaskAccessDenied)
testutil.AssertAppError(t, err, http.StatusForbidden, "error.task_access_denied")
}
func TestTaskService_ListTasks(t *testing.T) {
@@ -239,7 +240,7 @@ func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
now := time.Now().UTC()
service.CancelTask(task.ID, user.ID, now)
_, err := service.CancelTask(task.ID, user.ID, now)
assert.ErrorIs(t, err, ErrTaskAlreadyCancelled)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.task_already_cancelled")
}
func TestTaskService_UncancelTask(t *testing.T) {

View File

@@ -3,11 +3,13 @@ package services
import (
"errors"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/repositories"
)
var (
// Deprecated: Use apperrors.NotFound("error.user_not_found") instead
ErrUserNotFound = errors.New("user not found")
)
@@ -27,7 +29,7 @@ func NewUserService(userRepo *repositories.UserRepository) *UserService {
func (s *UserService) ListUsersInSharedResidences(userID uint) ([]responses.UserSummary, error) {
users, err := s.userRepo.FindUsersInSharedResidences(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
var result []responses.UserSummary
@@ -48,10 +50,10 @@ func (s *UserService) ListUsersInSharedResidences(userID uint) ([]responses.User
func (s *UserService) GetUserIfSharedResidence(targetUserID, requestingUserID uint) (*responses.UserSummary, error) {
user, err := s.userRepo.FindUserIfSharedResidence(targetUserID, requestingUserID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
if user == nil {
return nil, ErrUserNotFound
return nil, apperrors.NotFound("error.user_not_found")
}
return &responses.UserSummary{
@@ -67,7 +69,7 @@ func (s *UserService) GetUserIfSharedResidence(targetUserID, requestingUserID ui
func (s *UserService) ListProfilesInSharedResidences(userID uint) ([]responses.UserProfileSummary, error) {
profiles, err := s.userRepo.FindProfilesInSharedResidences(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
var result []responses.UserProfileSummary

View File

@@ -36,38 +36,59 @@ func (c KanbanColumn) String() string {
// Context holds the data needed to categorize a task
type Context struct {
Task *models.Task
Now time.Time
Now time.Time // Always normalized to start of day
DaysThreshold int
}
// startOfDay normalizes a time to the start of that day (midnight)
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// normalizeToTimezone converts a date to start of day in a specific timezone.
// This is needed because task due dates are stored as midnight UTC, but we need
// to compare them as calendar dates in the user's timezone.
//
// Example: A task due Dec 17 is stored as 2025-12-17 00:00:00 UTC.
// For a user in Tokyo (UTC+9), we need to compare against Dec 17 in Tokyo time,
// not against the UTC timestamp.
func normalizeToTimezone(t time.Time, loc *time.Location) time.Time {
// Extract the calendar date (year, month, day) from the time
// regardless of its original timezone, then create midnight in target timezone
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
// NewContext creates a new categorization context with sensible defaults.
// Uses UTC time. For timezone-aware categorization, use NewContextWithTime.
// Uses UTC time, normalized to start of day.
// For timezone-aware categorization, use NewContextWithTime.
func NewContext(t *models.Task, daysThreshold int) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: t,
Now: time.Now().UTC(),
Now: startOfDay(time.Now().UTC()),
DaysThreshold: daysThreshold,
}
}
// NewContextWithTime creates a new categorization context with a specific time.
// Use this when you need timezone-aware categorization - pass the start of day
// in the user's timezone.
// The time is normalized to start of day for consistent date comparisons.
// Use this when you need timezone-aware categorization - pass the current time
// in the user's timezone (it will be normalized to start of day).
func NewContextWithTime(t *models.Task, daysThreshold int, now time.Time) *Context {
if daysThreshold <= 0 {
daysThreshold = 30
}
return &Context{
Task: t,
Now: now,
Now: startOfDay(now),
DaysThreshold: daysThreshold,
}
}
// ThresholdDate returns the date threshold for "due soon" categorization
// (start of day + daysThreshold days)
func (c *Context) ThresholdDate() time.Time {
return c.Now.AddDate(0, 0, c.DaysThreshold)
}
@@ -118,8 +139,24 @@ func (h *CancelledHandler) Handle(ctx *Context) KanbanColumn {
return h.HandleNext(ctx)
}
// ArchivedHandler checks if the task is archived
// Priority: 2 - Archived tasks go to cancelled column (both are "inactive" states)
type ArchivedHandler struct {
BaseHandler
}
func (h *ArchivedHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.IsArchived
// Archived tasks are placed in the cancelled column since both represent
// "inactive" task states that are removed from active workflow
if predicates.IsArchived(ctx.Task) {
return ColumnCancelled
}
return h.HandleNext(ctx)
}
// CompletedHandler checks if the task is completed (one-time task with completions and no next due date)
// Priority: 2
// Priority: 3
type CompletedHandler struct {
BaseHandler
}
@@ -134,7 +171,7 @@ func (h *CompletedHandler) Handle(ctx *Context) KanbanColumn {
}
// InProgressHandler checks if the task status is "In Progress"
// Priority: 3
// Priority: 4
type InProgressHandler struct {
BaseHandler
}
@@ -148,7 +185,7 @@ func (h *InProgressHandler) Handle(ctx *Context) KanbanColumn {
}
// OverdueHandler checks if the task is overdue based on NextDueDate or DueDate
// Priority: 4
// Priority: 5
type OverdueHandler struct {
BaseHandler
}
@@ -158,14 +195,22 @@ func (h *OverdueHandler) Handle(ctx *Context) KanbanColumn {
// Note: We don't use predicates.IsOverdue here because the chain has already
// filtered out cancelled and completed tasks. We just need the date check.
effectiveDate := predicates.EffectiveDate(ctx.Task)
if effectiveDate != nil && effectiveDate.Before(ctx.Now) {
if effectiveDate == nil {
return h.HandleNext(ctx)
}
// Normalize the effective date to the same timezone as ctx.Now for proper
// calendar date comparison. Task dates are stored as UTC but represent
// calendar dates (YYYY-MM-DD), not timestamps.
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
if normalizedEffective.Before(ctx.Now) {
return ColumnOverdue
}
return h.HandleNext(ctx)
}
// DueSoonHandler checks if the task is due within the threshold period
// Priority: 5
// Priority: 6
type DueSoonHandler struct {
BaseHandler
}
@@ -173,16 +218,24 @@ type DueSoonHandler struct {
func (h *DueSoonHandler) Handle(ctx *Context) KanbanColumn {
// Uses predicate: predicates.EffectiveDate
effectiveDate := predicates.EffectiveDate(ctx.Task)
if effectiveDate == nil {
return h.HandleNext(ctx)
}
// Normalize the effective date to the same timezone as ctx.Now for proper
// calendar date comparison. Task dates are stored as UTC but represent
// calendar dates (YYYY-MM-DD), not timestamps.
normalizedEffective := normalizeToTimezone(*effectiveDate, ctx.Now.Location())
threshold := ctx.ThresholdDate()
if effectiveDate != nil && effectiveDate.Before(threshold) {
if normalizedEffective.Before(threshold) {
return ColumnDueSoon
}
return h.HandleNext(ctx)
}
// UpcomingHandler is the final handler that catches all remaining tasks
// Priority: 6 (lowest - default)
// Priority: 7 (lowest - default)
type UpcomingHandler struct {
BaseHandler
}
@@ -206,14 +259,16 @@ type Chain struct {
func NewChain() *Chain {
// Build the chain in priority order (first handler has highest priority)
cancelled := &CancelledHandler{}
archived := &ArchivedHandler{}
completed := &CompletedHandler{}
inProgress := &InProgressHandler{}
overdue := &OverdueHandler{}
dueSoon := &DueSoonHandler{}
upcoming := &UpcomingHandler{}
// Chain them together: cancelled -> completed -> inProgress -> overdue -> dueSoon -> upcoming
cancelled.SetNext(completed).
// Chain them together: cancelled -> archived -> completed -> inProgress -> overdue -> dueSoon -> upcoming
cancelled.SetNext(archived).
SetNext(completed).
SetNext(inProgress).
SetNext(overdue).
SetNext(dueSoon).

View File

@@ -233,3 +233,315 @@ func TestNewContext_DefaultThreshold(t *testing.T) {
t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
}
}
// ============================================================================
// TIMEZONE TESTS
// These tests verify that kanban categorization works correctly across timezones.
// The key insight: a task's due date is stored as a date (YYYY-MM-DD), but
// categorization depends on "what day is it NOW" in the user's timezone.
// ============================================================================
func TestTimezone_SameTaskDifferentCategorization(t *testing.T) {
// Scenario: A task due on Dec 17, 2025
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC)
// But 8 AM on Dec 17 in Tokyo (+9 hours)
// The task should be "due_soon" for UTC user but already in "due_soon" for Tokyo
// (not overdue yet for either - both are still on or before Dec 17)
// Task due Dec 17, 2025 (stored as midnight UTC)
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// User in UTC: It's Dec 16, 2025 at 11 PM UTC
utcTime := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
// User in Tokyo: Same instant but it's Dec 17, 2025 at 8 AM local
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoTime := utcTime.In(tokyo) // Same instant, different representation
// For UTC user: Dec 17 is tomorrow (1 day away) - should be due_soon
resultUTC := categorization.CategorizeTaskWithTime(task, 30, utcTime)
if resultUTC != categorization.ColumnDueSoon {
t.Errorf("UTC (Dec 16): expected due_soon, got %v", resultUTC)
}
// For Tokyo user: Dec 17 is TODAY - should still be due_soon (not overdue)
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
if resultTokyo != categorization.ColumnDueSoon {
t.Errorf("Tokyo (Dec 17): expected due_soon, got %v", resultTokyo)
}
}
func TestTimezone_TaskBecomesOverdue_DifferentTimezones(t *testing.T) {
// Scenario: A task due on Dec 16, 2025
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC) - due_soon
// At 8 AM UTC on Dec 17 - now overdue
// But for Tokyo user at 11 PM UTC (8 AM Dec 17 Tokyo) - already overdue
taskDueDate := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Case 1: UTC user at 11 PM on Dec 16 - task is due TODAY, so due_soon
utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
resultUTCEvening := categorization.CategorizeTaskWithTime(task, 30, utcDec16Evening)
if resultUTCEvening != categorization.ColumnDueSoon {
t.Errorf("UTC Dec 16 evening: expected due_soon, got %v", resultUTCEvening)
}
// Case 2: UTC user at 8 AM on Dec 17 - task is now OVERDUE
utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
resultUTCMorning := categorization.CategorizeTaskWithTime(task, 30, utcDec17Morning)
if resultUTCMorning != categorization.ColumnOverdue {
t.Errorf("UTC Dec 17 morning: expected overdue, got %v", resultUTCMorning)
}
// Case 3: Tokyo user at the same instant as case 1
// 11 PM UTC = 8 AM Dec 17 in Tokyo
// For Tokyo user, Dec 16 was yesterday, so task is OVERDUE
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoTime := utcDec16Evening.In(tokyo)
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
if resultTokyo != categorization.ColumnOverdue {
t.Errorf("Tokyo (same instant as UTC Dec 16 evening): expected overdue, got %v", resultTokyo)
}
}
func TestTimezone_InternationalDateLine(t *testing.T) {
// Test across the international date line
// Auckland (UTC+13) vs Honolulu (UTC-10)
// 23 hour difference!
// Task due Dec 17, 2025
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// At midnight UTC on Dec 17
utcTime := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
// Auckland: Dec 17 midnight UTC = Dec 17, 1 PM local (UTC+13)
// Task is due today in Auckland - should be due_soon
auckland, _ := time.LoadLocation("Pacific/Auckland")
aucklandTime := utcTime.In(auckland)
resultAuckland := categorization.CategorizeTaskWithTime(task, 30, aucklandTime)
if resultAuckland != categorization.ColumnDueSoon {
t.Errorf("Auckland (Dec 17, 1 PM): expected due_soon, got %v", resultAuckland)
}
// Honolulu: Dec 17 midnight UTC = Dec 16, 2 PM local (UTC-10)
// Task is due tomorrow in Honolulu - should be due_soon
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
honoluluTime := utcTime.In(honolulu)
resultHonolulu := categorization.CategorizeTaskWithTime(task, 30, honoluluTime)
if resultHonolulu != categorization.ColumnDueSoon {
t.Errorf("Honolulu (Dec 16, 2 PM): expected due_soon, got %v", resultHonolulu)
}
// Now advance to Dec 18 midnight UTC
// Auckland: Dec 18, 1 PM local - task due Dec 17 is now OVERDUE
// Honolulu: Dec 17, 2 PM local - task due Dec 17 is TODAY (due_soon)
utcDec18 := time.Date(2025, 12, 18, 0, 0, 0, 0, time.UTC)
aucklandDec18 := utcDec18.In(auckland)
resultAuckland2 := categorization.CategorizeTaskWithTime(task, 30, aucklandDec18)
if resultAuckland2 != categorization.ColumnOverdue {
t.Errorf("Auckland (Dec 18): expected overdue, got %v", resultAuckland2)
}
honoluluDec17 := utcDec18.In(honolulu)
resultHonolulu2 := categorization.CategorizeTaskWithTime(task, 30, honoluluDec17)
if resultHonolulu2 != categorization.ColumnDueSoon {
t.Errorf("Honolulu (Dec 17): expected due_soon, got %v", resultHonolulu2)
}
}
func TestTimezone_DueSoonThreshold_CrossesTimezones(t *testing.T) {
// Test that the 30-day threshold is calculated correctly in different timezones
// Task due 29 days from now (within threshold for both timezones)
// Task due 31 days from now (outside threshold)
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
// Task due in 29 days
due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
task29 := &models.Task{
NextDueDate: timePtr(due29Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Task due in 31 days
due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
task31 := &models.Task{
NextDueDate: timePtr(due31Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// UTC user
result29UTC := categorization.CategorizeTaskWithTime(task29, 30, now)
if result29UTC != categorization.ColumnDueSoon {
t.Errorf("29 days (UTC): expected due_soon, got %v", result29UTC)
}
result31UTC := categorization.CategorizeTaskWithTime(task31, 30, now)
if result31UTC != categorization.ColumnUpcoming {
t.Errorf("31 days (UTC): expected upcoming, got %v", result31UTC)
}
// Tokyo user at same instant
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoNow := now.In(tokyo)
result29Tokyo := categorization.CategorizeTaskWithTime(task29, 30, tokyoNow)
if result29Tokyo != categorization.ColumnDueSoon {
t.Errorf("29 days (Tokyo): expected due_soon, got %v", result29Tokyo)
}
result31Tokyo := categorization.CategorizeTaskWithTime(task31, 30, tokyoNow)
if result31Tokyo != categorization.ColumnUpcoming {
t.Errorf("31 days (Tokyo): expected upcoming, got %v", result31Tokyo)
}
}
func TestTimezone_StartOfDayNormalization(t *testing.T) {
// Test that times are normalized to start of day in the given timezone
// A task due Dec 17
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Test that different times on the SAME DAY produce the SAME result
// All of these should evaluate to "Dec 16" (today), making Dec 17 "due_soon"
times := []time.Time{
time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC), // Midnight
time.Date(2025, 12, 16, 6, 0, 0, 0, time.UTC), // 6 AM
time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC), // Noon
time.Date(2025, 12, 16, 18, 0, 0, 0, time.UTC), // 6 PM
time.Date(2025, 12, 16, 23, 59, 59, 0, time.UTC), // Just before midnight
}
for _, nowTime := range times {
result := categorization.CategorizeTaskWithTime(task, 30, nowTime)
if result != categorization.ColumnDueSoon {
t.Errorf("At %v: expected due_soon, got %v", nowTime.Format("15:04:05"), result)
}
}
}
func TestTimezone_DST_Transitions(t *testing.T) {
// Test behavior during daylight saving time transitions
// Los Angeles transitions from PDT to PST in early November
la, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
t.Skip("America/Los_Angeles timezone not available")
}
// Task due Nov 3, 2025 (DST ends in LA on Nov 2, 2025)
taskDueDate := time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Nov 2 at 11 PM LA time (during DST transition)
// This should still be Nov 2, so Nov 3 is tomorrow (due_soon)
laNov2Late := time.Date(2025, 11, 2, 23, 0, 0, 0, la)
result := categorization.CategorizeTaskWithTime(task, 30, laNov2Late)
if result != categorization.ColumnDueSoon {
t.Errorf("Nov 2 late evening LA: expected due_soon, got %v", result)
}
// Nov 3 at 1 AM LA time (after DST ends)
// This is Nov 3, so task is due today (due_soon)
laNov3Early := time.Date(2025, 11, 3, 1, 0, 0, 0, la)
result = categorization.CategorizeTaskWithTime(task, 30, laNov3Early)
if result != categorization.ColumnDueSoon {
t.Errorf("Nov 3 early morning LA: expected due_soon, got %v", result)
}
// Nov 4 at any time (after due date)
laNov4 := time.Date(2025, 11, 4, 8, 0, 0, 0, la)
result = categorization.CategorizeTaskWithTime(task, 30, laNov4)
if result != categorization.ColumnOverdue {
t.Errorf("Nov 4 LA: expected overdue, got %v", result)
}
}
func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
// Test CategorizeTasksIntoColumnsWithTime with timezone-aware categorization
// Tasks with various due dates
dec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
dec17 := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
jan15 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
tasks := []models.Task{
{BaseModel: models.BaseModel{ID: 1}, NextDueDate: timePtr(dec16)}, // Due Dec 16
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: timePtr(dec17)}, // Due Dec 17
{BaseModel: models.BaseModel{ID: 3}, NextDueDate: timePtr(jan15)}, // Due Jan 15
}
// Categorize as of Dec 17 midnight UTC
now := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
result := categorization.CategorizeTasksIntoColumnsWithTime(tasks, 30, now)
// Dec 16 should be overdue (yesterday)
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 1 {
t.Errorf("Expected task 1 (Dec 16) in overdue column, got %d tasks", len(result[categorization.ColumnOverdue]))
}
// Dec 17 (today) and Jan 15 (29 days away) should both be in due_soon
// Dec 17 to Jan 15 = 29 days (Dec 17-31 = 14 days, Jan 1-15 = 15 days)
dueSoonTasks := result[categorization.ColumnDueSoon]
if len(dueSoonTasks) != 2 {
t.Errorf("Expected 2 tasks in due_soon column, got %d", len(dueSoonTasks))
}
// Verify both task 2 and 3 are in due_soon
foundTask2 := false
foundTask3 := false
for _, task := range dueSoonTasks {
if task.ID == 2 {
foundTask2 = true
}
if task.ID == 3 {
foundTask3 = true
}
}
if !foundTask2 {
t.Errorf("Expected task 2 (Dec 17) in due_soon column")
}
if !foundTask3 {
t.Errorf("Expected task 3 (Jan 15) in due_soon column")
}
}

View File

@@ -570,8 +570,9 @@ func TestAllThreeLayersMatch(t *testing.T) {
})
}
// TestSameDayOverdueConsistency is a regression test for the DATE vs TIMESTAMP bug.
// It verifies all three layers handle same-day tasks consistently.
// TestSameDayOverdueConsistency is a regression test for day-based overdue logic.
// With day-based comparisons, a task due TODAY (at any time) is NOT overdue during that day.
// It only becomes overdue the NEXT day. This test verifies all three layers agree.
func TestSameDayOverdueConsistency(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
@@ -606,17 +607,16 @@ func TestSameDayOverdueConsistency(t *testing.T) {
categorizationResult := categorization.CategorizeTask(&loadedTask, 30) == categorization.ColumnOverdue
// If current time is after midnight, all should say overdue
if now.After(todayMidnight) {
if !predicateResult {
t.Error("Predicate says NOT overdue, but time is after midnight")
}
if !scopeResult {
t.Error("Scope says NOT overdue, but time is after midnight")
}
if !categorizationResult {
t.Error("Categorization says NOT overdue, but time is after midnight")
}
// With day-based comparison: task due TODAY is NOT overdue during that day.
// All three layers should say NOT overdue.
if predicateResult {
t.Error("Predicate incorrectly says overdue for same-day task")
}
if scopeResult {
t.Error("Scope incorrectly says overdue for same-day task")
}
if categorizationResult {
t.Error("Categorization incorrectly says overdue for same-day task")
}
// Most importantly: all three must agree

View File

@@ -91,16 +91,18 @@ func EffectiveDate(task *models.Task) *time.Time {
return task.DueDate
}
// IsOverdue returns true if the task's effective date is in the past.
// IsOverdue returns true if the task's effective date is before today.
//
// A task is overdue when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is before the given time
// - That date is before the start of the current day
// - The task is not completed, cancelled, or archived
//
// Note: A task due "today" is NOT overdue. It becomes overdue tomorrow.
//
// SQL equivalent (in scopes.go ScopeOverdue):
//
// COALESCE(next_due_date, due_date) < ?
// COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?)
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsOverdue(task *models.Task, now time.Time) bool {
@@ -111,20 +113,25 @@ func IsOverdue(task *models.Task, now time.Time) bool {
if effectiveDate == nil {
return false
}
return effectiveDate.Before(now)
// Compare against start of today, not current time
// A task due "today" should not be overdue until tomorrow
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return effectiveDate.Before(startOfDay)
}
// IsDueSoon returns true if the task's effective date is within the threshold.
//
// A task is "due soon" when:
// - It has an effective date (NextDueDate or DueDate)
// - That date is >= now AND < (now + daysThreshold)
// - That date is >= start of today AND < start of (today + daysThreshold)
// - The task is not completed, cancelled, archived, or already overdue
//
// Note: Uses start of day for comparisons so tasks due "today" are included.
//
// SQL equivalent (in scopes.go ScopeDueSoon):
//
// COALESCE(next_due_date, due_date) >= ?
// AND COALESCE(next_due_date, due_date) < ?
// COALESCE(next_due_date, due_date) >= DATE_TRUNC('day', ?)
// AND COALESCE(next_due_date, due_date) < DATE_TRUNC('day', ?) + interval 'N days'
// AND NOT (next_due_date IS NULL AND EXISTS completion)
// AND is_cancelled = false AND is_archived = false
func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
@@ -135,18 +142,22 @@ func IsDueSoon(task *models.Task, now time.Time, daysThreshold int) bool {
if effectiveDate == nil {
return false
}
threshold := now.AddDate(0, 0, daysThreshold)
// Due soon = not overdue AND before threshold
return !effectiveDate.Before(now) && effectiveDate.Before(threshold)
// Use start of day for comparisons
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
threshold := startOfDay.AddDate(0, 0, daysThreshold)
// Due soon = not overdue (>= start of today) AND before threshold
return !effectiveDate.Before(startOfDay) && effectiveDate.Before(threshold)
}
// IsUpcoming returns true if the task is due after the threshold or has no due date.
//
// A task is "upcoming" when:
// - It has no effective date, OR
// - Its effective date is >= (now + daysThreshold)
// - Its effective date is >= start of (today + daysThreshold)
// - The task is not completed, cancelled, or archived
//
// Note: Uses start of day for comparisons for consistency with other predicates.
//
// This is the default category for tasks that don't match other criteria.
func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
if !IsActive(task) || IsCompleted(task) {
@@ -156,7 +167,9 @@ func IsUpcoming(task *models.Task, now time.Time, daysThreshold int) bool {
if effectiveDate == nil {
return true // No due date = upcoming
}
threshold := now.AddDate(0, 0, daysThreshold)
// Use start of day for comparisons
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
threshold := startOfDay.AddDate(0, 0, daysThreshold)
return !effectiveDate.Before(threshold)
}

View File

@@ -187,6 +187,8 @@ func TestIsOverdue(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
tomorrow := now.AddDate(0, 0, 1)
// Start of today - this is what a DATE column stores (midnight)
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tests := []struct {
name string
@@ -216,6 +218,17 @@ func TestIsOverdue(t *testing.T) {
now: now,
expected: false,
},
{
name: "not overdue: task due today (start of day)",
task: &models.Task{
NextDueDate: timePtr(startOfToday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now, // Current time during the day
expected: false,
},
{
name: "not overdue: cancelled task",
task: &models.Task{
@@ -291,6 +304,8 @@ func TestIsDueSoon(t *testing.T) {
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
// Start of today - this is what a DATE column stores (midnight)
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
tests := []struct {
name string
@@ -311,6 +326,18 @@ func TestIsDueSoon(t *testing.T) {
daysThreshold: 30,
expected: true,
},
{
name: "due soon: task due today (start of day)",
task: &models.Task{
NextDueDate: timePtr(startOfToday),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
},
now: now, // Current time during the day
daysThreshold: 30,
expected: true,
},
{
name: "not due soon: beyond threshold",
task: &models.Task{

View File

@@ -98,66 +98,65 @@ func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
// ScopeOverdue returns a scope for overdue tasks.
//
// A task is overdue when its effective date (COALESCE(next_due_date, due_date))
// is before the given time, and it's active and not completed.
// is before the start of the given day, and it's active and not completed.
//
// Note: A task due "today" is NOT overdue. It becomes overdue tomorrow.
//
// Predicate equivalent: IsOverdue(task, now)
//
// SQL: COALESCE(next_due_date, due_date) < ?::timestamp AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp because PostgreSQL DATE columns compared
// against string literals (which is how GORM passes time.Time) use date comparison,
// not timestamp comparison. For example:
// - '2025-12-07'::date < '2025-12-07 17:00:00' = false (compares dates only)
// - '2025-12-07'::date < '2025-12-07 17:00:00'::timestamp = true (compares timestamp)
// SQL: COALESCE(next_due_date, due_date) < ? AND active AND not_completed
func ScopeOverdue(now time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// Compute start of day in Go for database-agnostic comparison
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", now)
Where("COALESCE(next_due_date, due_date) < ?", startOfDay)
}
}
// ScopeDueSoon returns a scope for tasks due within the threshold.
//
// A task is "due soon" when its effective date is >= now AND < (now + threshold),
// A task is "due soon" when its effective date is >= start of today AND < start of (today + threshold),
// and it's active and not completed.
//
// Note: Uses day-level comparisons so tasks due "today" are included.
//
// Predicate equivalent: IsDueSoon(task, now, daysThreshold)
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeDueSoon(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
// Compute start of day and threshold in Go for database-agnostic comparison
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
threshold := startOfDay.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", now).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", threshold)
Where("COALESCE(next_due_date, due_date) >= ?", startOfDay).
Where("COALESCE(next_due_date, due_date) < ?", threshold)
}
}
// ScopeUpcoming returns a scope for tasks due after the threshold or with no due date.
//
// A task is "upcoming" when its effective date is >= (now + threshold) OR is null,
// A task is "upcoming" when its effective date is >= start of (today + threshold) OR is null,
// and it's active and not completed.
//
// Note: Uses start of day for comparisons for consistency with other scopes.
//
// Predicate equivalent: IsUpcoming(task, now, daysThreshold)
//
// SQL: (COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL))
// SQL: (COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL))
//
// AND active AND not_completed
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
threshold := now.AddDate(0, 0, daysThreshold)
// Compute threshold as start of day + N days in Go for database-agnostic comparison
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
threshold := startOfDay.AddDate(0, 0, daysThreshold)
return db.Scopes(ScopeActive, ScopeNotCompleted).
Where(
"COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp OR (next_due_date IS NULL AND due_date IS NULL)",
"COALESCE(next_due_date, due_date) >= ? OR (next_due_date IS NULL AND due_date IS NULL)",
threshold,
)
}
@@ -165,17 +164,12 @@ func ScopeUpcoming(now time.Time, daysThreshold int) func(db *gorm.DB) *gorm.DB
// ScopeDueInRange returns a scope for tasks with effective date in a range.
//
// SQL: COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp
//
// AND COALESCE(next_due_date, due_date)::timestamp < ?::timestamp
//
// NOTE: We explicitly cast to timestamp for consistent comparison with DATE columns.
// See ScopeOverdue for detailed explanation.
// SQL: COALESCE(next_due_date, due_date) >= ? AND COALESCE(next_due_date, due_date) < ?
func ScopeDueInRange(start, end time.Time) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.
Where("COALESCE(next_due_date, due_date)::timestamp >= ?::timestamp", start).
Where("COALESCE(next_due_date, due_date)::timestamp < ?::timestamp", end)
Where("COALESCE(next_due_date, due_date) >= ?", start).
Where("COALESCE(next_due_date, due_date) < ?", end)
}
}

View File

@@ -356,8 +356,9 @@ func TestScopeOverdueMatchesPredicate(t *testing.T) {
}
}
// TestScopeOverdueWithSameDayTask tests the DATE vs TIMESTAMP comparison edge case
// This is a regression test for the bug where tasks due "today" were not counted as overdue
// TestScopeOverdueWithSameDayTask tests day-based overdue comparison.
// With day-based logic, a task due TODAY is NOT overdue during that same day.
// It only becomes overdue the NEXT day. Both scope and predicate should agree.
func TestScopeOverdueWithSameDayTask(t *testing.T) {
if testDB == nil {
t.Skip("Database not available")
@@ -397,16 +398,15 @@ func TestScopeOverdueWithSameDayTask(t *testing.T) {
}
}
// Both should agree: if it's past midnight, the task due at midnight is overdue
// Both should agree: with day-based comparison, task due today is NOT overdue
if len(scopeResults) != len(predicateResults) {
t.Errorf("DATE vs TIMESTAMP mismatch! Scope returned %d, predicate returned %d",
t.Errorf("Scope/predicate mismatch! Scope returned %d, predicate returned %d",
len(scopeResults), len(predicateResults))
t.Logf("This indicates the PostgreSQL DATE/TIMESTAMP comparison bug may have returned")
}
// If current time is after midnight, task should be overdue
if now.After(todayMidnight) && len(scopeResults) != 1 {
t.Errorf("Task due at midnight should be overdue after midnight, got %d results", len(scopeResults))
// With day-based comparison, task due today should NOT be overdue (it's due soon)
if len(scopeResults) != 0 {
t.Errorf("Task due today should NOT be overdue, got %d results (expected 0)", len(scopeResults))
}
}

View File

@@ -8,14 +8,16 @@ import (
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/validator"
)
var i18nOnce sync.Once
@@ -68,14 +70,16 @@ func SetupTestDB(t *testing.T) *gorm.DB {
return db
}
// SetupTestRouter creates a test Gin router
func SetupTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
return gin.New()
// SetupTestRouter creates a test Echo router with the custom error handler
func SetupTestRouter() *echo.Echo {
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
return e
}
// MakeRequest makes a test HTTP request and returns the response
func MakeRequest(router *gin.Engine, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
func MakeRequest(router *echo.Echo, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var reqBody *bytes.Buffer
if body != nil {
jsonBody, _ := json.Marshal(body)
@@ -90,9 +94,9 @@ func MakeRequest(router *gin.Engine, method, path string, body interface{}, toke
req.Header.Set("Authorization", "Token "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// ParseJSON parses JSON response body into a map
@@ -287,11 +291,13 @@ func AssertStatusCode(t *testing.T, w *httptest.ResponseRecorder, expected int)
}
// MockAuthMiddleware creates middleware that sets a test user in context
func MockAuthMiddleware(user *models.User) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("auth_user", user)
c.Set("auth_token", "test-token")
c.Next()
func MockAuthMiddleware(user *models.User) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("auth_user", user)
c.Set("auth_token", "test-token")
return next(c)
}
}
}
@@ -329,3 +335,22 @@ func CreateTestDocument(t *testing.T, db *gorm.DB, residenceID, createdByID uint
require.NoError(t, err)
return doc
}
// AssertAppError asserts that an error is an AppError with a specific status code and message key
func AssertAppError(t *testing.T, err error, expectedCode int, expectedMessageKey string) {
require.Error(t, err, "expected an error")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr, "expected an AppError")
require.Equal(t, expectedCode, appErr.Code, "unexpected status code")
require.Equal(t, expectedMessageKey, appErr.MessageKey, "unexpected message key")
}
// AssertAppErrorCode asserts that an error is an AppError with a specific status code
func AssertAppErrorCode(t *testing.T, err error, expectedCode int) {
require.Error(t, err, "expected an error")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr, "expected an AppError")
require.Equal(t, expectedCode, appErr.Code, "unexpected status code")
}

View File

@@ -0,0 +1,102 @@
package validator
import (
"net/http"
"reflect"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
// CustomValidator wraps go-playground/validator for Echo
type CustomValidator struct {
validator *validator.Validate
}
// NewCustomValidator creates a new validator instance
func NewCustomValidator() *CustomValidator {
v := validator.New()
// Use JSON tag names for field names in errors
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
return &CustomValidator{validator: v}
}
// Validate implements echo.Validator interface
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return err
}
return nil
}
// ValidationErrorResponse is the structured error response format
type ValidationErrorResponse struct {
Error string `json:"error"`
Fields map[string]FieldError `json:"fields,omitempty"`
}
// FieldError represents a single field validation error
type FieldError struct {
Message string `json:"message"`
Tag string `json:"tag"`
}
// FormatValidationErrors converts validator errors to structured response
func FormatValidationErrors(err error) *ValidationErrorResponse {
if validationErrors, ok := err.(validator.ValidationErrors); ok {
fields := make(map[string]FieldError)
for _, fe := range validationErrors {
fieldName := fe.Field()
fields[fieldName] = FieldError{
Message: formatMessage(fe),
Tag: fe.Tag(),
}
}
return &ValidationErrorResponse{
Error: "Validation failed",
Fields: fields,
}
}
return &ValidationErrorResponse{Error: err.Error()}
}
// HTTPError returns an echo.HTTPError with validation details
func HTTPError(c echo.Context, err error) error {
return c.JSON(http.StatusBadRequest, FormatValidationErrors(err))
}
func formatMessage(fe validator.FieldError) string {
switch fe.Tag() {
case "required":
return "This field is required"
case "required_without":
return "This field is required when " + fe.Param() + " is not provided"
case "required_with":
return "This field is required when " + fe.Param() + " is provided"
case "email":
return "Must be a valid email address"
case "min":
return "Must be at least " + fe.Param() + " characters"
case "max":
return "Must be at most " + fe.Param() + " characters"
case "len":
return "Must be exactly " + fe.Param() + " characters"
case "oneof":
return "Must be one of: " + fe.Param()
case "url":
return "Must be a valid URL"
case "uuid":
return "Must be a valid UUID"
default:
return "Invalid value"
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
)
@@ -29,6 +30,7 @@ const (
// Handler handles background job processing
type Handler struct {
db *gorm.DB
taskRepo *repositories.TaskRepository
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
@@ -46,6 +48,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
return &Handler{
db: db,
taskRepo: repositories.NewTaskRepository(db),
pushClient: pushClient,
emailService: emailService,
notificationService: notificationService,
@@ -72,8 +75,6 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.TaskReminderHour
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
dayAfterTomorrow := today.AddDate(0, 0, 2)
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Task reminder check")
@@ -112,22 +113,18 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour")
// Step 2: Query tasks due today or tomorrow only for eligible users
// Completion detection logic matches internal/task/predicates.IsCompleted:
// A task is "completed" when NextDueDate == nil AND has at least one completion.
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
var dueSoonTasks []models.Task
err = h.db.Preload("Completions").Preload("Residence").
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
today, dayAfterTomorrow, today, dayAfterTomorrow).
Where("is_cancelled = false").
Where("is_archived = false").
// Exclude completed tasks (matches scopes.ScopeNotCompleted)
Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
Find(&dueSoonTasks).Error
// Step 2: Query tasks due today or tomorrow using the single-purpose repository function
// Uses the same scopes as kanban for consistency, with IncludeInProgress=true
// so users still get notified about in-progress tasks that are due soon.
opts := repositories.TaskFilterOptions{
UserIDs: eligibleUserIDs,
IncludeInProgress: true, // Notifications should include in-progress tasks
PreloadResidence: true,
PreloadCompletions: true,
}
// Due soon = due within 2 days (today and tomorrow)
dueSoonTasks, err := h.taskRepo.GetDueSoonTasks(now, 2, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to query tasks due soon")
return err
@@ -176,7 +173,6 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.OverdueReminderHour
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Overdue reminder check")
@@ -215,21 +211,17 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour")
// Step 2: Query overdue tasks only for eligible users
// Completion detection logic matches internal/task/predicates.IsCompleted:
// A task is "completed" when NextDueDate == nil AND has at least one completion.
// See internal/task/scopes.ScopeNotCompleted for the SQL equivalent.
var overdueTasks []models.Task
err = h.db.Preload("Completions").Preload("Residence").
Where("due_date < ? OR next_due_date < ?", today, today).
Where("is_cancelled = false").
Where("is_archived = false").
// Exclude completed tasks (matches scopes.ScopeNotCompleted)
Where("NOT (next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id))").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
Find(&overdueTasks).Error
// Step 2: Query overdue tasks using the single-purpose repository function
// Uses the same scopes as kanban for consistency, with IncludeInProgress=true
// so users still get notified about in-progress tasks that are overdue.
opts := repositories.TaskFilterOptions{
UserIDs: eligibleUserIDs,
IncludeInProgress: true, // Notifications should include in-progress tasks
PreloadResidence: true,
PreloadCompletions: true,
}
overdueTasks, err := h.taskRepo.GetOverdueTasks(now, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to query overdue tasks")
return err