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

170
MIGRATION_STATUS.md Normal file
View File

@@ -0,0 +1,170 @@
# Phase 4: Gin to Echo Handler Migration Status
## Completed Files
### ✅ auth_handler.go
- **Status**: Fully migrated
- **Methods migrated**: 13 methods
- Login, Register, Logout, CurrentUser, UpdateProfile
- VerifyEmail, ResendVerification
- ForgotPassword, VerifyResetCode, ResetPassword
- AppleSignIn, GoogleSignIn
- **Key changes applied**:
- Import changed from gin to echo
- Added validator import
- All handlers return error
- c.Bind + c.Validate pattern implemented
- c.MustGet → c.Get
- gin.H → map[string]interface{}
- c.Request.Context() → c.Request().Context()
- All c.JSON calls use `return`
## Remaining Files to Migrate
### 🔧 residence_handler.go
- **Status**: Partially migrated (needs cleanup)
- **Methods**: 13 methods
- **Issue**: Sed-based automated migration created syntax errors
- **Next steps**: Manual cleanup needed
### ⏳ task_handler.go
- **Methods**: ~17 methods
- **Complexity**: High (multipart form handling for completions)
- **Special considerations**:
- Has multipart/form-data handling in CreateCompletion
- Multiple lookup endpoints (categories, priorities, frequencies)
### ⏳ contractor_handler.go
- **Methods**: 8 methods
- **Complexity**: Medium
### ⏳ document_handler.go
- **Methods**: 8 methods
- **Complexity**: High (multipart form handling)
- **Special considerations**: File upload in CreateDocument
### ⏳ notification_handler.go
- **Methods**: 9 methods
- **Complexity**: Medium
- **Special considerations**: Query parameters for pagination
### ⏳ subscription_handler.go
- **Status**: Unknown
- **Estimated complexity**: Medium
### ⏳ upload_handler.go
- **Methods**: 4 methods
- **Complexity**: Medium
- **Special considerations**: c.FormFile handling, c.DefaultQuery
### ⏳ user_handler.go
- **Methods**: 3 methods
- **Complexity**: Low
### ⏳ media_handler.go
- **Status**: Unknown
- **Estimated complexity**: Medium
### ⏳ static_data_handler.go
- **Methods**: Unknown
- **Complexity**: Low (likely just lookups)
### ⏳ task_template_handler.go
- **Status**: Unknown
- **Estimated complexity**: Medium
### ⏳ tracking_handler.go
- **Status**: Unknown
- **Estimated complexity**: Low
### ⏳ subscription_webhook_handler.go
- **Status**: Unknown
- **Estimated complexity**: Medium-High (webhook handling)
## Migration Pattern
All handlers must follow these transformations:
```go
// BEFORE (Gin)
func (h *Handler) Method(c *gin.Context) {
user := c.MustGet(middleware.AuthUserKey).(*models.User)
var req requests.SomeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
result, err := h.service.DoSomething(&req)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, result)
}
// AFTER (Echo)
func (h *Handler) Method(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
var req requests.SomeRequest
if err := c.Bind(&req); err != nil {
return c.JSON(400, map[string]interface{}{"error": err.Error()})
}
if err := c.Validate(&req); err != nil {
return c.JSON(400, validator.FormatValidationErrors(err))
}
result, err := h.service.DoSomething(&req)
if err != nil {
return c.JSON(500, map[string]interface{}{"error": err.Error()})
}
return c.JSON(200, result)
}
```
## Critical Context Changes
| Gin | Echo |
|-----|------|
| `c.MustGet()` | `c.Get()` |
| `c.ShouldBindJSON()` | `c.Bind()` + `c.Validate()` |
| `c.JSON(status, data)` | `return c.JSON(status, data)` |
| `c.Query("key")` | `c.QueryParam("key")` |
| `c.DefaultQuery("k", "v")` | Manual: `if v := c.QueryParam("k"); v != "" { } else { v = "default" }` |
| `c.PostForm("field")` | `c.FormValue("field")` |
| `c.GetHeader("X-...")` | `c.Request().Header.Get("X-...")` |
| `c.Request.Context()` | `c.Request().Context()` |
| `c.Status(code)` | `return c.NoContent(code)` |
| `gin.H{...}` | `map[string]interface{}{...}` |
## Multipart Form Handling
For handlers with file uploads (document_handler, task_handler):
```go
// Request parsing
c.Request.ParseMultipartForm(32 << 20) // Same
c.PostForm("field") c.FormValue("field")
c.FormFile("file") // Same
```
## Next Steps
1. Clean up residence_handler.go manually
2. Migrate contractor_handler.go (simpler, good template)
3. Migrate smaller files: user_handler.go, upload_handler.go, notification_handler.go
4. Migrate complex files: task_handler.go, document_handler.go
5. Migrate remaining files
6. Test compilation
7. Update route registration (if not already done in Phase 3)
## Automation Lessons Learned
- Sed-based bulk replacements are error-prone for complex Go code
- Better approach: Manual migration with copy-paste for repetitive patterns
- Python script provided in migrate_handlers.py (not yet tested)
- Best approach: Methodical manual migration with validation at each step

View File

@@ -1,10 +1,10 @@
# Casera API (Go)
Go implementation of the Casera property management API, built with Gin, GORM, and GoAdmin.
Go implementation of the Casera property management API, built with Echo, GORM, and GoAdmin.
## Tech Stack
- **HTTP Framework**: [Gin](https://github.com/gin-gonic/gin)
- **HTTP Framework**: [Echo v4](https://github.com/labstack/echo)
- **ORM**: [GORM](https://gorm.io/) with PostgreSQL
- **Push Notifications**: Direct APNs (via [apns2](https://github.com/sideshow/apns2)) + FCM HTTP API
- **Admin Panel**: [GoAdmin](https://github.com/GoAdminGroup/go-admin)

View File

@@ -164,12 +164,12 @@ func main() {
StorageService: storageService,
MonitoringService: monitoringService,
}
r := router.SetupRouter(deps)
e := router.SetupRouter(deps)
// Create HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: r,
Handler: e,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,

View File

@@ -0,0 +1,417 @@
# Gin to Echo Framework Migration Guide
This document outlines the migration of the MyCrib Go API from Gin to Echo v4 with direct go-playground/validator integration.
## Overview
| Aspect | Before | After |
|--------|--------|-------|
| Framework | Gin v1.10 | Echo v4.11 |
| Validation | Gin's binding wrapper | Direct go-playground/validator |
| Validation tags | `binding:"..."` | `validate:"..."` |
| Error format | Inconsistent | Structured field-level |
## Scope
- **56 files** requiring modification
- **110+ routes** across public and admin APIs
- **45 handlers** (17 core + 28 admin)
- **5 middleware** files
---
## API Mapping Reference
### Context Methods
| Gin | Echo | Notes |
|-----|------|-------|
| `c.ShouldBindJSON(&req)` | `c.Bind(&req)` | Bind only, no validation |
| `c.ShouldBindJSON(&req)` | `c.Validate(&req)` | Validation only (call after Bind) |
| `c.Param("id")` | `c.Param("id")` | Same |
| `c.Query("name")` | `c.QueryParam("name")` | Different method name |
| `c.DefaultQuery("k","v")` | Custom helper | No built-in equivalent |
| `c.PostForm("field")` | `c.FormValue("field")` | Different method name |
| `c.FormFile("file")` | `c.FormFile("file")` | Same |
| `c.Get(key)` | `c.Get(key)` | Same |
| `c.Set(key, val)` | `c.Set(key, val)` | Same |
| `c.MustGet(key)` | `c.Get(key)` | No MustGet, add nil check |
| `c.JSON(status, obj)` | `return c.JSON(status, obj)` | Must return |
| `c.AbortWithStatusJSON()` | `return c.JSON()` | Return-based flow |
| `c.Status(200)` | `return c.NoContent(200)` | Different method |
| `c.GetHeader("X-...")` | `c.Request().Header.Get()` | Access via Request |
| `c.ClientIP()` | `c.RealIP()` | Different method name |
| `c.File(path)` | `return c.File(path)` | Must return |
| `gin.H{...}` | `echo.Map{...}` | Or `map[string]any{}` |
### Handler Signature
```go
// Gin - void return
func (h *Handler) Method(c *gin.Context) {
// ...
c.JSON(200, response)
}
// Echo - error return (MUST return)
func (h *Handler) Method(c echo.Context) error {
// ...
return c.JSON(200, response)
}
```
### Middleware Signature
```go
// Gin
func MyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// before
c.Next()
// after
}
}
// Echo
func MyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// before
err := next(c)
// after
return err
}
}
}
```
---
## Validation Changes
### Tag Migration
Change all `binding:` tags to `validate:` tags:
```go
// Before
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// After
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
}
```
### Supported Validation Tags
| Tag | Description |
|-----|-------------|
| `required` | Field must be present and non-zero |
| `required_without=Field` | Required if other field is empty |
| `omitempty` | Skip validation if empty |
| `email` | Must be valid email format |
| `min=N` | Minimum length/value |
| `max=N` | Maximum length/value |
| `len=N` | Exact length |
| `oneof=a b c` | Must be one of listed values |
| `url` | Must be valid URL |
| `uuid` | Must be valid UUID |
### New Error Response Format
```json
{
"error": "Validation failed",
"fields": {
"email": {
"message": "Must be a valid email address",
"tag": "email"
},
"password": {
"message": "Must be at least 8 characters",
"tag": "min"
}
}
}
```
**Mobile clients must update** error parsing to handle the `fields` object.
---
## Handler Migration Pattern
### Before (Gin)
```go
func (h *AuthHandler) Login(c *gin.Context) {
var req requests.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
})
return
}
user := c.MustGet(middleware.AuthUserKey).(*models.User)
response, err := h.authService.Login(&req)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
```
### After (Echo)
```go
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, responses.ErrorResponse{
Error: "Invalid request body",
})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
user := c.Get(middleware.AuthUserKey).(*models.User)
response, err := h.authService.Login(&req)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": err.Error()})
}
return c.JSON(http.StatusOK, response)
}
```
---
## Middleware Migration Examples
### Auth Middleware
```go
// Before (Gin)
func (m *AuthMiddleware) TokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := extractToken(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
user, err := m.validateToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Set(AuthUserKey, user)
c.Next()
}
}
// After (Echo)
func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token, err := extractToken(c)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Unauthorized"})
}
user, err := m.validateToken(token)
if err != nil {
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "Invalid token"})
}
c.Set(AuthUserKey, user)
return next(c)
}
}
}
```
### Timezone Middleware
```go
// Before (Gin)
func TimezoneMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tzName := c.GetHeader(TimezoneHeader)
loc := parseTimezone(tzName)
c.Set(TimezoneKey, loc)
c.Next()
}
}
// After (Echo)
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)
c.Set(TimezoneKey, loc)
return next(c)
}
}
}
```
---
## Router Setup
### Before (Gin)
```go
func SetupRouter(deps *Dependencies) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
r.Use(cors.New(cors.Config{...}))
api := r.Group("/api")
api.POST("/auth/login/", authHandler.Login)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.GET("/residences/", residenceHandler.List)
return r
}
```
### After (Echo)
```go
func SetupRouter(deps *Dependencies) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Validator = validator.NewCustomValidator()
// Trailing slash handling
e.Pre(middleware.AddTrailingSlash())
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{...}))
api := e.Group("/api")
api.POST("/auth/login/", authHandler.Login)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.GET("/residences/", residenceHandler.List)
return e
}
```
---
## Helper Functions
### DefaultQuery Helper
```go
// internal/echohelpers/helpers.go
func DefaultQuery(c echo.Context, key, defaultValue string) string {
if val := c.QueryParam(key); val != "" {
return val
}
return defaultValue
}
```
### Safe Context Get
```go
// internal/middleware/helpers.go
func GetAuthUser(c echo.Context) *models.User {
val := c.Get(AuthUserKey)
if val == nil {
return nil
}
user, ok := val.(*models.User)
if !ok {
return nil
}
return user
}
func MustGetAuthUser(c echo.Context) (*models.User, error) {
user := GetAuthUser(c)
if user == nil {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "Authentication required")
}
return user, nil
}
```
---
## Testing Changes
### Test Setup
```go
// Before (Gin)
func SetupTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
return gin.New()
}
// After (Echo)
func SetupTestRouter() *echo.Echo {
e := echo.New()
e.Validator = validator.NewCustomValidator()
return e
}
```
### Making Test Requests
```go
// Before (Gin)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/auth/login/", body)
router.ServeHTTP(w, req)
// After (Echo) - Same pattern works
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/auth/login/", body)
e.ServeHTTP(rec, req)
```
---
## Important Notes
1. **All handlers must return error** - Echo uses return-based flow
2. **Trailing slashes** - Use `middleware.AddTrailingSlash()` to maintain API compatibility
3. **Type assertions** - Always add nil checks when using `c.Get()`
4. **CORS** - Use Echo's built-in CORS middleware
5. **Bind vs Validate** - Echo separates these; call both for full validation
---
## Files Modified
| Category | Count |
|----------|-------|
| New files | 2 (`validator/`, `echohelpers/`) |
| DTOs | 6 |
| Middleware | 5 |
| Core handlers | 14 |
| Admin handlers | 28 |
| Router | 2 |
| Tests | 6 |
| **Total** | **63** |

21
go.mod
View File

@@ -3,13 +3,13 @@ module github.com/treytartt/casera-api
go 1.24.0
require (
github.com/gin-contrib/cors v1.7.3
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.23.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hibiken/asynq v0.25.1
github.com/jung-kurt/gofpdf v1.16.2
github.com/labstack/echo/v4 v4.11.4
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/redis/go-redis/v9 v9.17.1
github.com/rs/zerolog v1.34.0
@@ -32,24 +32,19 @@ require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
@@ -60,15 +55,12 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -82,15 +74,14 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect

45
go.sum
View File

@@ -13,14 +13,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -35,12 +29,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -58,9 +46,9 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -71,7 +59,6 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -96,17 +83,17 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
@@ -119,11 +106,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ=
github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -168,15 +150,10 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -185,10 +162,10 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -207,8 +184,6 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=

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

114
migrate_handlers.py Normal file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Migrate Gin handlers to Echo handlers
"""
import re
import sys
def migrate_handler_file(content):
"""Migrate a single handler file from Gin to Echo"""
# 1. Import changes
content = re.sub(r'"github\.com/gin-gonic/gin"', '"github.com/labstack/echo/v4"', content)
# Add validator import if not present
if '"github.com/treytartt/casera-api/internal/validator"' not in content:
content = re.sub(
r'("github\.com/treytartt/casera-api/internal/services"\n)',
r'\1\t"github.com/treytartt/casera-api/internal/validator"\n',
content
)
# 2. Handler signatures - must return error
content = re.sub(
r'func \(h \*(\w+Handler)\) (\w+)\(c \*gin\.Context\) {',
r'func (h *\1) \2(c echo.Context) error {',
content
)
# 3. c.MustGet -> c.Get
content = re.sub(r'c\.MustGet\(', 'c.Get(', content)
# 4. Bind and validate separately
# Match ShouldBindJSON pattern and replace with Bind + Validate
def replace_bind_validate(match):
indent = match.group(1)
var_name = match.group(2)
error_block = match.group(3)
# Replace the error block to use 'return'
error_block_fixed = re.sub(r'\n(\t+)c\.JSON\(', r'\n\1return c.JSON(', error_block)
error_block_fixed = re.sub(r'\n(\t+)\}\n(\t+)return\n', r'\n\1}\n', error_block_fixed)
return (f'{indent}if err := c.Bind(&{var_name}); err != nil {{\n{error_block_fixed}'
f'{indent}}}\n'
f'{indent}if err := c.Validate(&{var_name}); err != nil {{\n'
f'{indent}\treturn c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))\n'
f'{indent}}}')
# Handle ShouldBindJSON with error handling
content = re.sub(
r'(\t+)if err := c\.ShouldBindJSON\(&(\w+)\); err != nil \{((?:\n(?:\1\t.*|))*\n\1\}\n\1\treturn\n)',
replace_bind_validate,
content
)
# Handle optional ShouldBindJSON (no error check)
content = re.sub(r'c\.ShouldBindJSON\(&(\w+)\)', r'c.Bind(&\1)', content)
# 5. gin.H -> map[string]interface{}
content = re.sub(r'gin\.H\{', 'map[string]interface{}{', content)
# 6. c.Query -> c.QueryParam
content = re.sub(r'c\.Query\(', 'c.QueryParam(', content)
# 7. c.PostForm -> c.FormValue
content = re.sub(r'c\.PostForm\(', 'c.FormValue(', content)
# 8. c.GetHeader -> c.Request().Header.Get
content = re.sub(r'c\.GetHeader\(', 'c.Request().Header.Get(', content)
# 9. c.Request.Context() -> c.Request().Context()
content = re.sub(r'c\.Request\.Context\(\)', 'c.Request().Context()', content)
# 10. All c.JSON, c.Status calls must have 'return'
# Match c.JSON without return
content = re.sub(
r'(\n\t+)c\.JSON\(([^)]+\))',
r'\1return c.JSON(\2',
content
)
# Match c.Status -> c.NoContent
content = re.sub(
r'(\n\t+)c\.Status\(([^)]+)\)',
r'\1return c.NoContent(\2)',
content
)
# 11. Fix double 'return return' issues
content = re.sub(r'return return c\.', 'return c.', content)
# 12. Remove standalone 'return' at end of functions (now returns values)
# This is tricky - we need to remove lines that are just '\treturn\n}' at function end
content = re.sub(r'\n\treturn\n\}$', r'\n}', content, flags=re.MULTILINE)
content = re.sub(r'\n(\t+)return\n(\1)\}', r'\n\2}', content)
return content
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: migrate_handlers.py <handler_file.go>")
sys.exit(1)
filename = sys.argv[1]
with open(filename, 'r') as f:
content = f.read()
migrated = migrate_handler_file(content)
with open(filename, 'w') as f:
f.write(migrated)
print(f"Migrated {filename}")

View File

@@ -5,7 +5,7 @@ import (
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -57,66 +57,75 @@ func InitLoggerWithWriter(debug bool, additionalWriter io.Writer) {
log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger()
}
// GinLogger returns a Gin middleware for request logging
func GinLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// EchoLogger returns an Echo middleware for request logging
func EchoLogger() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
req := c.Request()
path := req.URL.Path
raw := req.URL.RawQuery
// Process request
c.Next()
// Process request
err := next(c)
// Log after request
end := time.Now()
latency := end.Sub(start)
// Log after request
end := time.Now()
latency := end.Sub(start)
if raw != "" {
path = path + "?" + raw
}
msg := "Request"
if len(c.Errors) > 0 {
msg = c.Errors.String()
}
event := log.Info()
statusCode := c.Writer.Status()
if statusCode >= 400 && statusCode < 500 {
event = log.Warn()
} else if statusCode >= 500 {
event = log.Error()
}
event.
Str("method", c.Request.Method).
Str("path", path).
Int("status", statusCode).
Str("ip", c.ClientIP()).
Dur("latency", latency).
Str("user-agent", c.Request.UserAgent()).
Msg(msg)
}
}
// GinRecovery returns a Gin middleware for panic recovery
func GinRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Error().
Interface("error", err).
Str("path", c.Request.URL.Path).
Str("method", c.Request.Method).
Msg("Panic recovered")
c.AbortWithStatusJSON(500, gin.H{
"error": "Internal server error",
})
if raw != "" {
path = path + "?" + raw
}
}()
c.Next()
res := c.Response()
statusCode := res.Status
msg := "Request"
if err != nil {
msg = err.Error()
}
event := log.Info()
if statusCode >= 400 && statusCode < 500 {
event = log.Warn()
} else if statusCode >= 500 {
event = log.Error()
}
event.
Str("method", req.Method).
Str("path", path).
Int("status", statusCode).
Str("ip", c.RealIP()).
Dur("latency", latency).
Str("user-agent", req.UserAgent()).
Msg(msg)
return err
}
}
}
// EchoRecovery returns an Echo middleware for panic recovery
func EchoRecovery() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
log.Error().
Interface("error", err).
Str("path", c.Request().URL.Path).
Str("method", c.Request().Method).
Msg("Panic recovered")
c.JSON(500, map[string]interface{}{
"error": "Internal server error",
})
}
}()
return next(c)
}
}
}