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:
170
MIGRATION_STATUS.md
Normal file
170
MIGRATION_STATUS.md
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
417
docs/GIN_TO_ECHO_MIGRATION.md
Normal file
417
docs/GIN_TO_ECHO_MIGRATION.md
Normal 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
21
go.mod
@@ -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
45
go.sum
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬ifications).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(¬if)
|
||||
}
|
||||
|
||||
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(¬ification, 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(¬ification))
|
||||
return c.JSON(http.StatusOK, h.toNotificationDetailResponse(¬ification))
|
||||
}
|
||||
|
||||
// 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(¬ification, 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(¬ification).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(¬ification, 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(¬ification).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(¬ification, id)
|
||||
c.JSON(http.StatusOK, h.toNotificationResponse(¬ification))
|
||||
return c.JSON(http.StatusOK, h.toNotificationResponse(¬ification))
|
||||
}
|
||||
|
||||
// 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(¬ification).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(¬ification).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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
97
internal/apperrors/errors.go
Normal file
97
internal/apperrors/errors.go
Normal 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
|
||||
}
|
||||
66
internal/apperrors/handler.go
Normal file
66
internal/apperrors/handler.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
45
internal/echohelpers/helpers.go
Normal file
45
internal/echohelpers/helpers.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, ¬ification); 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
102
internal/validator/validator.go
Normal file
102
internal/validator/validator.go
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
114
migrate_handlers.py
Normal 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}")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user