diff --git a/api_specification.md b/api_specification.md index d1178b9..e684d78 100644 --- a/api_specification.md +++ b/api_specification.md @@ -1,128 +1,490 @@ -# Boxes API - Frontend Specification +# Boxes API Specification -This document outlines the API endpoints for a simple inventory management system called "Boxes". +## Base URL +`/api/v1` -**Authentication:** +## Authentication +- All endpoints except `/login` require JWT authentication +- JWT token must be provided in the Authorization header as `Bearer ` +- Tokens expire after 24 hours -* All endpoints (except `/login`) require a valid JWT token in the `Authorization` header, formatted as `Bearer `. -* To obtain a token, send a POST request to `/login` with the following JSON payload: +## Endpoints +### Authentication + +#### Login +- **URL**: `/login` +- **Method**: `POST` +- **Auth Required**: No +- **Request Body**: + ```json + { + "username": "string", + "password": "string" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: ```json { - "username": "your_username", - "password": "your_password" + "token": "string" } ``` +- **Error Responses**: + - **Code**: 401 UNAUTHORIZED + - **Content**: "Invalid username or password" -* Successful login will return a JSON response with the token: - - ```json - { - "token": "your_jwt_token" - } - ``` - -**Endpoints:** - -**1. Boxes:** - -* **GET /boxes:** - * Returns a list of all boxes. - * Response: Array of Box objects +### Boxes +#### Get All Boxes +- **URL**: `/boxes` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: ```json [ { - "id": 1, - "name": "Kitchen" - }, - { - "id": 2, - "name": "Bedroom" + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string" } ] ``` -* **POST /boxes:** - * Creates a new box. - * Request body: JSON object with the box name - +#### Create Box +- **URL**: `/boxes` +- **Method**: `POST` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "name": "string" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: ```json { - "name": "New Box" + "id": "number", + "name": "string" } ``` - * Response: JSON object with the created box's ID and name - +#### Get Box +- **URL**: `/boxes/{id}` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: ```json { - "id": 3, - "name": "New Box" + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string" } ``` +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Box not found" -* **DELETE /boxes/{id}:** - * Deletes the box with the specified ID. - * Response: 204 No Content +#### Delete Box +- **URL**: `/boxes/{id}` +- **Method**: `DELETE` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 204 NO CONTENT +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Box not found" -**2. Items:** - -* **GET /items:** - * Returns a list of all items. - * Response: Array of Item objects +### Items +#### Get All Items +- **URL**: `/items` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: ```json [ { - "id": 1, - "name": "Fork", - "description": "Silverware", - "box_id": 1, - "image_path": "path/to/image.jpg" - }, - { - "id": 2, - "name": "Pillow", - "description": "Fluffy", - "box_id": 2, - "image_path": "path/to/another_image.png" + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string", + "description": "string", + "box_id": "number", + "image_path": "string", + "tags": [ + { + "ID": "number", + "name": "string", + "description": "string", + "color": "string" + } + ] } ] ``` -* **POST /items:** - * Creates a new item. - * Request body: JSON object with item details - +#### Create Item +- **URL**: `/items` +- **Method**: `POST` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "name": "string", + "description": "string", + "box_id": "number" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: ```json { - "name": "Spoon", - "description": "For soup", - "box_id": 1, - "image_path": "path/to/spoon_image.jpg" + "id": "number", + "name": "string" } ``` - * Response: JSON object with the created item's ID and name - +#### Get Item +- **URL**: `/items/{id}` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: ```json { - "id": 3, - "name": "Spoon" + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string", + "description": "string", + "box_id": "number", + "image_path": "string", + "tags": [ + { + "ID": "number", + "name": "string", + "description": "string", + "color": "string" + } + ] + } + ``` +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" + +#### Update Item +- **URL**: `/items/{id}` +- **Method**: `PUT` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "name": "string", + "description": "string", + "box_id": "number" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: Updated item object +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" + +#### Delete Item +- **URL**: `/items/{id}` +- **Method**: `DELETE` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 204 NO CONTENT +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" + +#### Get Items in Box +- **URL**: `/boxes/{id}/items` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: Array of item objects +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Items not found" + +#### Search Items +- **URL**: `/search/items` +- **Method**: `GET` +- **Auth Required**: Yes +- **Query Parameters**: + - `q`: Search query string +- **Success Response**: + - **Code**: 200 + - **Content**: Array of matching item objects +- **Error Response**: + - **Code**: 400 BAD REQUEST + - **Content**: "Search query is required" + +### Item Images + +#### Upload Item Image +- **URL**: `/items/{id}/upload` +- **Method**: `POST` +- **Auth Required**: Yes +- **Content-Type**: `multipart/form-data` +- **Form Parameters**: + - `image`: File upload +- **Success Response**: + - **Code**: 200 + - **Content**: + ```json + { + "imagePath": "string" + } + ``` +- **Error Responses**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" + - **Code**: 400 BAD REQUEST + - **Content**: "Unable to parse form" + +#### Get Item Image +- **URL**: `/items/{id}/image` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: Image file +- **Note**: Returns default image if no image is found + +### Tags + +#### Get All Tags +- **URL**: `/tags` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: + ```json + [ + { + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string", + "description": "string", + "color": "string" // hex color code + } + ] + ``` + +#### Create Tag +- **URL**: `/tags` +- **Method**: `POST` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "name": "string", + "description": "string", + "color": "string" // hex color code, optional + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: Created tag object +- **Error Responses**: + - **Code**: 400 BAD REQUEST + - **Content**: "Tag name is required" + - **Code**: 409 CONFLICT + - **Content**: "Tag name already exists" + +#### Update Tag +- **URL**: `/tags/{id}` +- **Method**: `PUT` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "name": "string", + "description": "string", + "color": "string" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: Updated tag object +- **Error Responses**: + - **Code**: 404 NOT FOUND + - **Content**: "Tag not found" + - **Code**: 409 CONFLICT + - **Content**: "Tag name already exists" + +#### Delete Tag +- **URL**: `/tags/{id}` +- **Method**: `DELETE` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 204 NO CONTENT +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Tag not found" + +### Item Tags + +#### Add Tags to Item +- **URL**: `/items/{id}/tags` +- **Method**: `POST` +- **Auth Required**: Yes +- **Request Body**: + ```json + [1, 2, 3] // Array of tag IDs + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: Updated item object with tags +- **Error Responses**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" + - **Code**: 400 BAD REQUEST + - **Content**: "Tag {id} not found" + +#### Remove Tag from Item +- **URL**: `/items/{id}/tags/{tagId}` +- **Method**: `DELETE` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 204 NO CONTENT +- **Error Responses**: + - **Code**: 404 NOT FOUND + - **Content**: "Item not found" or "Tag not found" + +#### Get Items by Tag +- **URL**: `/tags/{id}/items` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: + ```json + [ + { + "ID": "number", + "CreatedAt": "timestamp", + "UpdatedAt": "timestamp", + "DeletedAt": "timestamp|null", + "name": "string", + "description": "string", + "box_id": "number", + "image_path": "string", + "tags": [ + { + "ID": "number", + "name": "string", + "description": "string", + "color": "string" + } + ] + } + ] + ``` +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "Tag not found" + +### Admin Endpoints + +#### Get All Users +- **URL**: `/admin/user` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: Array of user objects + +#### Create User +- **URL**: `/admin/user` +- **Method**: `POST` +- **Auth Required**: Yes +- **Request Body**: + ```json + { + "username": "string", + "password": "string", + "email": "string" + } + ``` +- **Success Response**: + - **Code**: 200 + - **Content**: Created user object + +#### Get User +- **URL**: `/admin/user/{id}` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: User object +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "User not found" + +#### Delete User +- **URL**: `/admin/user/{id}` +- **Method**: `DELETE` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 204 NO CONTENT +- **Error Response**: + - **Code**: 404 NOT FOUND + - **Content**: "User not found" + +#### Backup Database +- **URL**: `/admin/db` +- **Method**: `GET` +- **Auth Required**: Yes +- **Success Response**: + - **Code**: 200 + - **Content**: Database file + +#### Restore Database +- **URL**: `/admin/db` +- **Method**: `POST` +- **Auth Required**: Yes +- **Content-Type**: `multipart/form-data` +- **Form Parameters**: + - `database`: Database file +- **Success Response**: + - **Code**: 200 + - **Content**: + ```json + { + "message": "Database restored successfully" } ``` -* **GET /items/{id}:** - * Retrieves the item with the specified ID. - * Response: Item object -* **GET /boxes/{id}/items:** - * Retrieves all items within the box with the specified ID. - * Response: Array of Item objects -* **PUT /items/{id}:** - * Updates the item with the specified ID. - * Request body: JSON object with updated item details - * Response: Updated Item object -* **DELETE /items/{id}:** - * Deletes the item with the specified ID. - * Response: 204 No Content +## Error Responses +All endpoints may return these common errors: +- **401 Unauthorized**: Missing or invalid authentication token +- **500 Internal Server Error**: Server-side error \ No newline at end of file diff --git a/db.go b/db.go index b4ad292..7363aac 100644 --- a/db.go +++ b/db.go @@ -13,6 +13,21 @@ type Box struct { Name string `json:"name"` } +// Define the Tag model +type Tag struct { + gorm.Model + Name string `json:"name" gorm:"uniqueIndex"` + Description string `json:"description"` + Color string `json:"color" gorm:"default:'#808080'"` // Hex color code + Items []Item `gorm:"many2many:item_tags;"` +} + +// ItemTag represents the many-to-many relationship between Items and Tags +type ItemTag struct { + ItemID uint `gorm:"primaryKey"` + TagID uint `gorm:"primaryKey"` +} + // Define the Item model type Item struct { gorm.Model @@ -20,6 +35,7 @@ type Item struct { Description string `json:"description"` BoxID uint `json:"box_id"` ImagePath string `json:"image_path"` + Tags []Tag `json:"tags" gorm:"many2many:item_tags;"` } // Define the User model @@ -30,82 +46,18 @@ type User struct { Email string `json:"email"` } -// ConnectDB establishes a connection to the database and sets up the schema func ConnectDB(dbPath string) (*gorm.DB, error) { - log := GetLogger() - if log != nil { - log.Info("Attempting to connect to database at: %s", dbPath) - } - db, err := gorm.Open("sqlite3", dbPath) if err != nil { - if log != nil { - log.Error("Failed to connect to database: %v", err) - } return nil, fmt.Errorf("failed to connect to database: %v", err) } - // Enable detailed SQL logging if we're in DEBUG mode - if log != nil && log.GetLogLevel() == "DEBUG" { - db.LogMode(true) - } - - if log != nil { - log.Info("Successfully connected to database") - } - - // Set auto_vacuum mode to ON - if err := db.Exec("PRAGMA auto_vacuum = ON;").Error; err != nil { - if log != nil { - log.Error("Failed to set auto_vacuum pragma: %v", err) - } - return nil, fmt.Errorf("failed to set auto_vacuum pragma: %v", err) - } - - if log != nil { - log.Info("Auto-vacuum mode enabled") - } + // set auto_vacuum mode to ON + // this automagically removes old rows from the database when idle + db.Exec("PRAGMA auto_vacuum = ON;") // AutoMigrate will create the tables if they don't exist - if err := autoMigrateSchema(db); err != nil { - if log != nil { - log.Error("Schema migration failed: %v", err) - } - return nil, err - } - - if log != nil { - log.Info("Database schema migration completed successfully") - } + db.AutoMigrate(&Box{}, &Item{}, &User{}, &Tag{}, &ItemTag{}) return db, nil } - -// autoMigrateSchema handles the database schema migration -func autoMigrateSchema(db *gorm.DB) error { - log := GetLogger() - if log != nil { - log.Info("Starting schema migration") - } - - // List of models to migrate - models := []interface{}{ - &Box{}, - &Item{}, - &User{}, - } - - for _, model := range models { - if err := db.AutoMigrate(model).Error; err != nil { - if log != nil { - log.Error("Failed to migrate model %T: %v", model, err) - } - return fmt.Errorf("failed to migrate model %T: %v", model, err) - } - if log != nil { - log.Debug("Successfully migrated model: %T", model) - } - } - - return nil -} diff --git a/item_handlers.go b/item_handlers.go index 1a0af4c..23e19c4 100644 --- a/item_handlers.go +++ b/item_handlers.go @@ -16,7 +16,12 @@ func GetItemsHandler(w http.ResponseWriter, r *http.Request) { log.Info("Received %s request to %s", r.Method, r.URL) var items []Item - db.Find(&items) + // Preload tags when getting items + if err := db.Preload("Tags").Find(&items).Error; err != nil { + log.Error("Failed to fetch items: %v", err) + http.Error(w, "Failed to fetch items", http.StatusInternalServerError) + return + } json.NewEncoder(w).Encode(items) } @@ -170,7 +175,7 @@ func GetItemHandler(w http.ResponseWriter, r *http.Request) { id := vars["id"] var item Item - if err := db.First(&item, id).Error; err != nil { + if err := db.Preload("Tags").First(&item, id).Error; err != nil { log.Warn("Item not found with ID: %s", id) http.Error(w, "Item not found", http.StatusNotFound) return @@ -185,7 +190,7 @@ func GetItemsInBoxHandler(w http.ResponseWriter, r *http.Request) { id := vars["id"] var items []Item - if err := db.Where("box_id = ?", id).Find(&items).Error; err != nil { + if err := db.Preload("Tags").Where("box_id = ?", id).Find(&items).Error; err != nil { log.Warn("Failed to fetch items for box ID: %s", id) http.Error(w, "Items not found", http.StatusNotFound) return diff --git a/main.go b/main.go index 32c23b5..78677c9 100644 --- a/main.go +++ b/main.go @@ -13,8 +13,9 @@ import ( "github.com/rs/cors" ) +// Global variables var ( - db *gorm.DB // Declare db globally + db *gorm.DB config *Config JWTSecret *[]byte ImageStorage *string @@ -22,165 +23,113 @@ var ( ) func main() { - - // Load configuration + // Load configuration first var err error config, err = loadAndValidateConfig() if err != nil { log.Fatalf("Failed to load config: %v", err) } - // Set up logging BEFORE logging config details - if err := setupLogging(config.LogFile); err != nil { - log.Fatalf("Failed to set up logging: %v", err) + // Initialize logger with config values + logger, err := Initialize(config.LogFile, "both") // "both" means log to both file and stdout + if err != nil { + log.Fatalf("Failed to initialize logger: %v", err) } - - log := GetLogger() - log.Printf("Config loaded successfully in main(), DB path %s\n", config.DatabasePath) + defer logger.Close() - // Now that logging is set up, log the config details + // Set up logging configuration + if err := setupLogging(config.LogLevel); err != nil { + logger.Error("Failed to set up logging: %v", err) + os.Exit(1) + } + + // Log configuration details logConfigDetails(config) - // Connect to the database + // Connect to database db, err = connectToDatabase(config) if err != nil { - log.Fatalf("Failed to connect to database: %v", err) + logger.Error("Failed to connect to database: %v", err) + os.Exit(1) } defer db.Close() - // Create routers - baseRouter := mux.NewRouter() - baseRouter.Use(loggingMiddleware) - apiRouter := createAPIRouter(baseRouter) - staticRouter := createStaticRouter(baseRouter, config.StaticFilesDir) - - // Set up routes - setupAPIRoutes(apiRouter) - setupStaticRoutes(staticRouter, config.StaticFilesDir) + // Create and configure the router + router := createRouter() // Create CORS handler corsHandler := createCORSHandler(config.AllowedOrigins) // Start the server - startServer(config.ListeningPort, corsHandler(baseRouter)) + startServer(config.ListeningPort, corsHandler(router)) } -func loadAndValidateConfig() (*Config, error) { - configFile := os.Getenv("BOXES_API_CONFIG") - if configFile == "" { - fmt.Println("BOXES_API_CONFIG not set") // print because logger isn't alive yet. - configFile = "./config/config.yaml" - } - config, err := LoadConfig(configFile) - if err != nil || config == nil { - return nil, fmt.Errorf("failed to load config: %v", err) - } +func createRouter() *mux.Router { + baseRouter := mux.NewRouter() + baseRouter.Use(loggingMiddleware) - // Validate the database path - if config.DatabasePath == "" { - return nil, fmt.Errorf("database path is not set in config") - } - DatabasePath = &config.DatabasePath + // API routes should be registered first with a strict prefix match + apiRouter := baseRouter.PathPrefix("/api/v1").Subrouter() + apiRouter.StrictSlash(true) // This ensures /api/v1/ and /api/v1 are treated the same + setupAPIRoutes(apiRouter) - // Set JWTSecret - jwtSecretBytes := []byte(config.JWTSecret) - JWTSecret = &jwtSecretBytes + // Static file serving should be last and only match if no API routes matched + staticRouter := baseRouter.NewRoute().Subrouter() + setupStaticRoutes(staticRouter, config.StaticFilesDir) - ImageStorage = &config.ImageStorageDir - - return config, nil -} - -func setupLogging(logFile string) error { - // Initialize returns a *Logger, but we don't need to store it - _, err := Initialize(logFile, config.LogOutput) - if err != nil { - return fmt.Errorf("failed to initialize logger: %v", err) - } - - // Get the logger instance - log := GetLogger() - if log == nil { - return fmt.Errorf("logger not initialized") - } - - // Set log level from config if specified - if config.LogLevel != "" { - if err := log.SetLogLevel(config.LogLevel); err != nil { - return fmt.Errorf("failed to set log level: %v", err) + // Add a catch-all NotFoundHandler to the base router + baseRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If the path starts with /api, return a 404 API error + if strings.HasPrefix(r.URL.Path, "/api/") { + http.Error(w, "API endpoint not found", http.StatusNotFound) + return } - } + // Otherwise serve the index.html for client-side routing + http.ServeFile(w, r, filepath.Join(config.StaticFilesDir, "index.html")) + }) - return nil -} -func logConfigDetails(config *Config) { - log := GetLogger() - if log == nil { - log.Warn("Warning: Logger not initialized when attempting to log config details") - return - } - - log.Info("Configuration loaded:") - log.Info("Database Path: %s", config.DatabasePath) - log.Info("Image Storage Dir: %s", config.ImageStorageDir) - log.Info("Log File: %s", config.LogFile) - log.Info("Log Level: %s", config.LogLevel) - log.Info("Log Output: %s", config.LogOutput) - log.Info("Listening Port: %d", config.ListeningPort) - log.Info("Allowed Origins: %s", config.AllowedOrigins) - - // Don't log the JWT secret for security reasons - log.Info("JWT Secret: [REDACTED]") -} - -func connectToDatabase(config *Config) (*gorm.DB, error) { - log := GetLogger() - if config == nil { - log.Error("config is nil in connectToDatabase") - return nil, fmt.Errorf("config is nil") - } - db, err := ConnectDB(config.DatabasePath) - if err != nil || db == nil { - log.Error("Failed to connect to database in connectToDatabase") - return nil, fmt.Errorf("failed to connect to database: %v", err) - } - log.Info("Connected to database in connectToDatabase") - return db, nil -} - -func createAPIRouter(baseRouter *mux.Router) *mux.Router { - return baseRouter.PathPrefix("/api/v1").Subrouter() -} - -func createStaticRouter(baseRouter *mux.Router, staticPath string) *mux.Router { - if err := validateStaticDirectory(staticPath); err != nil { - log.Fatalf("Static directory error: %v", err) - } - return baseRouter.PathPrefix("/").Subrouter() + return baseRouter } func setupAPIRoutes(router *mux.Router) { + // Public routes router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS") // Protected routes protected := router.NewRoute().Subrouter() protected.Use(AuthMiddleware) + // Box routes protected.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET", "OPTIONS") protected.Handle("/boxes", http.HandlerFunc(CreateBoxHandler)).Methods("POST", "OPTIONS") - protected.Handle("/boxes/{id}", http.HandlerFunc(DeleteBoxHandler)).Methods("DELETE", "OPTIONS") protected.Handle("/boxes/{id}", http.HandlerFunc(GetBoxHandler)).Methods("GET", "OPTIONS") + protected.Handle("/boxes/{id}", http.HandlerFunc(DeleteBoxHandler)).Methods("DELETE", "OPTIONS") + protected.Handle("/boxes/{id}/items", http.HandlerFunc(GetItemsInBoxHandler)).Methods("GET", "OPTIONS") + + // Item routes protected.Handle("/items", http.HandlerFunc(GetItemsHandler)).Methods("GET", "OPTIONS") protected.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(GetItemHandler)).Methods("GET", "OPTIONS") - protected.Handle("/boxes/{id}/items", http.HandlerFunc(GetItemsInBoxHandler)).Methods("GET", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(UpdateItemHandler)).Methods("PUT", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE", "OPTIONS") protected.Handle("/items/{id}/image", http.HandlerFunc(GetItemImageHandler)).Methods("GET", "OPTIONS") - protected.Handle("/search/items", http.HandlerFunc(SearchItemsHandler)).Methods("GET", "OPTIONS") protected.Handle("/items/{id}/upload", http.HandlerFunc(UploadItemImageHandler)).Methods("POST", "OPTIONS") + protected.Handle("/search/items", http.HandlerFunc(SearchItemsHandler)).Methods("GET", "OPTIONS") - setupManagementRoutes(protected.PathPrefix("/admin").Subrouter()) + // Tag routes + protected.Handle("/tags", http.HandlerFunc(GetTagsHandler)).Methods("GET", "OPTIONS") + protected.Handle("/tags", http.HandlerFunc(CreateTagHandler)).Methods("POST", "OPTIONS") + protected.Handle("/tags/{id}", http.HandlerFunc(UpdateTagHandler)).Methods("PUT", "OPTIONS") + protected.Handle("/tags/{id}", http.HandlerFunc(DeleteTagHandler)).Methods("DELETE", "OPTIONS") + protected.Handle("/tags/{id}/items", http.HandlerFunc(GetItemsByTagHandler)).Methods("GET", "OPTIONS") + + // Item-Tag relationship routes + protected.Handle("/items/{id}/tags", http.HandlerFunc(AddItemTagsHandler)).Methods("POST", "OPTIONS") + protected.Handle("/items/{id}/tags/{tagId}", http.HandlerFunc(RemoveItemTagHandler)).Methods("DELETE", "OPTIONS") + + // Admin routes + adminRouter := protected.PathPrefix("/admin").Subrouter() + setupManagementRoutes(adminRouter) } func setupManagementRoutes(router *mux.Router) { @@ -193,76 +142,175 @@ func setupManagementRoutes(router *mux.Router) { } func setupStaticRoutes(router *mux.Router, staticPath string) { - customHandler := createCustomStaticHandler(staticPath) - router.PathPrefix("/").Handler(http.StripPrefix("/", customHandler)) -} + // Ensure static directory exists + if err := validateStaticDirectory(staticPath); err != nil { + log.Printf("Warning: Static directory validation failed: %v", err) + } -func createCustomStaticHandler(staticPath string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - log := GetLogger() - if log != nil { - log.Info("Attempting to serve: %s from directory: %s", r.URL.Path, staticPath) - } + fileServer := http.FileServer(http.Dir(staticPath)) - fullPath := filepath.Join(staticPath, r.URL.Path) - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - if log != nil { - log.Warn("File not found: %s", fullPath) - } + // Only serve static files for paths that don't start with /api + router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := GetLogger() + + // Don't serve static files for API paths + if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } - http.FileServer(http.Dir(staticPath)).ServeHTTP(w, r) + + // Log static file request + if logger != nil { + logger.Debug("Static file request: %s", r.URL.Path) + } + + // Check if the file exists + path := filepath.Join(staticPath, r.URL.Path) + _, err := os.Stat(path) + + // If file doesn't exist, serve index.html for SPA routing + if os.IsNotExist(err) { + if logger != nil { + logger.Debug("File not found, serving index.html: %s", path) + } + http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) + return + } + + // Strip the leading "/" before serving + http.StripPrefix("/", fileServer).ServeHTTP(w, r) + })) +} + +func createCustomStaticHandler(staticPath string) http.HandlerFunc { + fileServer := http.FileServer(http.Dir(staticPath)) + return func(w http.ResponseWriter, r *http.Request) { + logger := GetLogger() + + // Log static file request + if logger != nil { + logger.Debug("Static file request: %s", r.URL.Path) + } + + // Check if the file exists + path := filepath.Join(staticPath, r.URL.Path) + _, err := os.Stat(path) + + // If file doesn't exist, serve index.html for SPA routing + if os.IsNotExist(err) { + if logger != nil { + logger.Debug("File not found, serving index.html: %s", path) + } + http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) + return + } + + fileServer.ServeHTTP(w, r) } } -func createCORSHandler(allowedOrigins string) func(http.Handler) http.Handler { - origins := strings.Split(allowedOrigins, ",") - if len(origins) == 0 { - origins = []string{"http://localhost:3000"} +func loadAndValidateConfig() (*Config, error) { + configFile := os.Getenv("BOXES_API_CONFIG") + if configFile == "" { + configFile = "config.yaml" // Default config path } - return cors.New(cors.Options{ - AllowedOrigins: origins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Authorization", "Content-Type"}, - ExposedHeaders: []string{"Content-Length", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Cache-Control", "Content-Language", "Content-Type", "Expires", "Last-Modified", "Pragma", "ETag"}, - AllowCredentials: true, - }).Handler + var err error + config, err := LoadConfig(configFile) + if err != nil { + return nil, fmt.Errorf("failed to load config: %v", err) + } + + // Set global variables + jwtSecretBytes := []byte(config.JWTSecret) + JWTSecret = &jwtSecretBytes + ImageStorage = &config.ImageStorageDir + DatabasePath = &config.DatabasePath + + return config, nil } -func startServer(port int, handler http.Handler) { - log := GetLogger() - if log != nil { - log.Info("Server starting on port %d", port) +func setupLogging(logLevel string) error { + logger := GetLogger() + if logger == nil { + return fmt.Errorf("logger not initialized") } - log.Info("Server listening on port %d\n", port) - log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), handler)) -} -func validateStaticDirectory(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Errorf("static directory does not exist: %s", path) + if logLevel != "" { + if err := logger.SetLogLevel(logLevel); err != nil { + return fmt.Errorf("failed to set log level: %v", err) + } } + return nil } -func loggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - username := "anonymous" - if user, ok := r.Context().Value(userKey).(string); ok { - username = user +func logConfigDetails(config *Config) { + logger := GetLogger() + if logger == nil { + return + } + + logger.Info("Configuration loaded:") + logger.Info("Database Path: %s", config.DatabasePath) + logger.Info("Image Storage Dir: %s", config.ImageStorageDir) + logger.Info("Log File: %s", config.LogFile) + logger.Info("Log Level: %s", config.LogLevel) + logger.Info("Listening Port: %d", config.ListeningPort) + logger.Info("Allowed Origins: %s", config.AllowedOrigins) + logger.Info("Static Files Dir: %s", config.StaticFilesDir) +} + +func connectToDatabase(config *Config) (*gorm.DB, error) { + if config == nil { + return nil, fmt.Errorf("config is nil") + } + return ConnectDB(config.DatabasePath) +} + +func createCORSHandler(allowedOrigins string) func(http.Handler) http.Handler { + corsOpts := cors.Options{ + AllowedOrigins: []string{allowedOrigins}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + ExposedHeaders: []string{"Content-Length"}, + AllowCredentials: true, + } + + // If allowedOrigins is "*", allow all origins + if allowedOrigins == "*" { + corsOpts.AllowedOrigins = []string{"*"} + } + + return cors.New(corsOpts).Handler +} + +func startServer(port int, handler http.Handler) { + logger := GetLogger() + addr := fmt.Sprintf(":%d", port) + + if logger != nil { + logger.Info("Server starting on port %d", port) + } + + if err := http.ListenAndServe(addr, handler); err != nil { + if logger != nil { + logger.Error("Server failed to start: %v", err) } + os.Exit(1) + } +} - // Create a response wrapper to capture the status code - rw := &responseWriter{w, http.StatusOK} - - next.ServeHTTP(rw, r) - - if log := GetLogger(); log != nil { - log.HTTPRequest(r.Method, r.URL.Path, username, rw.statusCode) +func validateStaticDirectory(path string) error { + if info, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("static directory does not exist: %s", path) } - }) + return fmt.Errorf("error accessing static directory: %v", err) + } else if !info.IsDir() { + return fmt.Errorf("static path is not a directory: %s", path) + } + return nil } type responseWriter struct { @@ -274,3 +322,22 @@ func (rw *responseWriter) WriteHeader(code int) { rw.statusCode = code rw.ResponseWriter.WriteHeader(code) } + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := GetLogger() + + username := "anonymous" + if user, ok := r.Context().Value(userKey).(string); ok { + username = user + } + + rw := &responseWriter{w, http.StatusOK} + + next.ServeHTTP(rw, r) + + if logger != nil { + logger.HTTPRequest(r.Method, r.URL.Path, username, rw.statusCode) + } + }) +} diff --git a/scripts/api.bash b/scripts/api.bash new file mode 100644 index 0000000..c68cb80 --- /dev/null +++ b/scripts/api.bash @@ -0,0 +1,39 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080/api/v1" + +# Login credentials +USERNAME="boxuser" +PASSWORD="boxuser" + +# Function to make an authenticated request +function authenticated_request() { + local method=$1 + local endpoint=$2 + local data=$3 + + # Get a new JWT token + TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + + # Make the authenticated request + curl -s -X $method -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$API_BASE_URL$endpoint" +} + +# Get all boxes +response=$(authenticated_request "GET" "/tags/1/items" "") + +echo $response + +# Check if the request was successful +if [[ $? -eq 0 ]]; then + # Pretty print the boxes using jq + echo "$response" | jq . +else + echo "Error: Failed to get boxes." +fi diff --git a/scripts/gem_script.bash b/scripts/gem_script.bash new file mode 100755 index 0000000..004d421 --- /dev/null +++ b/scripts/gem_script.bash @@ -0,0 +1,70 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080/api/v1" + +# Login credentials +USERNAME="boxuser" +PASSWORD="boxuser" + +# Usage message +usage() { + echo "Usage: $0 -m -e [-i ] [-d ]" + echo " -m HTTP method (GET, POST, PUT, DELETE)" + echo " -e API endpoint (e.g., /tags/1/items)" + echo " -i Optional element ID (appended to endpoint)" + echo " -d Optional data (for POST/PUT requests)" + exit 1 +} + +# Parse command line options +while getopts ":m:e:i:d:" opt; do + case $opt in + m) method="$OPTARG" ;; + e) endpoint="$OPTARG" ;; + i) id="$OPTARG" ;; + d) data="$OPTARG" ;; + \?) usage ;; + esac +done + +# Check if required options are provided +if [[ -z "$method" || -z "$endpoint" ]]; then + usage +fi + +# Construct the URL +url="$API_BASE_URL$endpoint" +if [[ -n "$id" ]]; then + url="$url/$id" +fi + +# Function to make an authenticated request +function authenticated_request() { + local method=$1 + local url=$2 + local data=$3 + + # Get a new JWT token + TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + + # Make the authenticated request + curl -s -X "$method" -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$url" +} + +# Make the API request +response=$(authenticated_request "$method" "$url" "$data") +echo "Raw response: $response" + +# Check if the request was successful +if [[ $? -eq 0 ]]; then + # Pretty print the response using jq + echo "$response" | jq . +else + echo "Error: Failed to make API request." +fi diff --git a/scripts/getitemsbytag.bash b/scripts/getitemsbytag.bash new file mode 100644 index 0000000..c68cb80 --- /dev/null +++ b/scripts/getitemsbytag.bash @@ -0,0 +1,39 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080/api/v1" + +# Login credentials +USERNAME="boxuser" +PASSWORD="boxuser" + +# Function to make an authenticated request +function authenticated_request() { + local method=$1 + local endpoint=$2 + local data=$3 + + # Get a new JWT token + TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + + # Make the authenticated request + curl -s -X $method -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$API_BASE_URL$endpoint" +} + +# Get all boxes +response=$(authenticated_request "GET" "/tags/1/items" "") + +echo $response + +# Check if the request was successful +if [[ $? -eq 0 ]]; then + # Pretty print the boxes using jq + echo "$response" | jq . +else + echo "Error: Failed to get boxes." +fi diff --git a/scripts/gettags.bash b/scripts/gettags.bash new file mode 100644 index 0000000..3b0ad5c --- /dev/null +++ b/scripts/gettags.bash @@ -0,0 +1,39 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080/api/v1" + +# Login credentials +USERNAME="boxuser" +PASSWORD="boxuser" + +# Function to make an authenticated request +function authenticated_request() { + local method=$1 + local endpoint=$2 + local data=$3 + + # Get a new JWT token + TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + + # Make the authenticated request + curl -s -X $method -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "$API_BASE_URL$endpoint" +} + +# Get all boxes +response=$(authenticated_request "GET" "/tags?include_items=true" "") + +echo $response + +# Check if the request was successful +if [[ $? -eq 0 ]]; then + # Pretty print the boxes using jq + echo "$response" | jq . +else + echo "Error: Failed to get boxes." +fi diff --git a/tags_handlers.go b/tags_handlers.go new file mode 100644 index 0000000..2847caf --- /dev/null +++ b/tags_handlers.go @@ -0,0 +1,319 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// TagRequest represents the request body for tag operations +type TagRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` +} + +// GetTagsHandler returns all tags +func GetTagsHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + loadItems := r.URL.Query().Get("include_items") == "true" + + var tags []Tag + query := db.Model(&Tag{}) + + // Only preload items if specifically requested + if loadItems { + query = query.Preload("Items") + } + + if err := query.Find(&tags).Error; err != nil { + log.Error("Failed to fetch tags: %v", err) + http.Error(w, "Failed to fetch tags", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(tags) +} + +// CreateTagHandler creates a new tag +func CreateTagHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + var req TagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error("Failed to decode tag creation request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate tag name + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + http.Error(w, "Tag name is required", http.StatusBadRequest) + return + } + + // Check for duplicate tag name + var existingTag Tag + if err := db.Where("name = ?", req.Name).First(&existingTag).Error; err == nil { + http.Error(w, "Tag name already exists", http.StatusConflict) + return + } + + tag := Tag{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + } + + if err := db.Create(&tag).Error; err != nil { + log.Error("Failed to create tag: %v", err) + http.Error(w, "Failed to create tag", http.StatusInternalServerError) + return + } + + log.DatabaseAction("create", fmt.Sprintf("Created tag: %s", tag.Name)) + json.NewEncoder(w).Encode(tag) +} + +// UpdateTagHandler updates an existing tag +func UpdateTagHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + vars := mux.Vars(r) + id := vars["id"] + + var tag Tag + if err := db.First(&tag, id).Error; err != nil { + http.Error(w, "Tag not found", http.StatusNotFound) + return + } + + var req TagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Check for duplicate name if name is being changed + if req.Name != tag.Name { + var existingTag Tag + if err := db.Where("name = ? AND id != ?", req.Name, id).First(&existingTag).Error; err == nil { + http.Error(w, "Tag name already exists", http.StatusConflict) + return + } + } + + tag.Name = req.Name + tag.Description = req.Description + tag.Color = req.Color + + if err := db.Save(&tag).Error; err != nil { + log.Error("Failed to update tag: %v", err) + http.Error(w, "Failed to update tag", http.StatusInternalServerError) + return + } + + log.DatabaseAction("update", fmt.Sprintf("Updated tag: %s", tag.Name)) + json.NewEncoder(w).Encode(tag) +} + +// DeleteTagHandler deletes a tag +func DeleteTagHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + vars := mux.Vars(r) + id := vars["id"] + + // Begin transaction + tx := db.Begin() + + // Remove tag associations + if err := tx.Table("item_tags").Where("tag_id = ?", id).Delete(ItemTag{}).Error; err != nil { + tx.Rollback() + log.Error("Failed to remove tag associations: %v", err) + http.Error(w, "Failed to delete tag", http.StatusInternalServerError) + return + } + + // Delete the tag + if err := tx.Delete(&Tag{}, id).Error; err != nil { + tx.Rollback() + log.Error("Failed to delete tag: %v", err) + http.Error(w, "Failed to delete tag", http.StatusInternalServerError) + return + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + log.Error("Failed to commit tag deletion: %v", err) + http.Error(w, "Failed to delete tag", http.StatusInternalServerError) + return + } + + log.DatabaseAction("delete", fmt.Sprintf("Deleted tag ID: %s", id)) + w.WriteHeader(http.StatusNoContent) +} + +// AddItemTagsHandler adds tags to an item +// AddItemTagsHandler adds tags to an item +func AddItemTagsHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + vars := mux.Vars(r) + itemID := vars["id"] + + log.Info("Attempting to add tags to item %s", itemID) + + var item Item + if err := db.First(&item, itemID).Error; err != nil { + log.Error("Failed to find item %s: %v", itemID, err) + http.Error(w, "Item not found", http.StatusNotFound) + return + } + + var tagIDs []uint + if err := json.NewDecoder(r.Body).Decode(&tagIDs); err != nil { + log.Error("Failed to decode tag IDs from request: %v", err) + http.Error(w, "Invalid request body - expecting array of tag IDs", http.StatusBadRequest) + return + } + + log.Info("Received request to add tags %v to item %s", tagIDs, itemID) + + // Begin transaction + tx := db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // First, fetch all the tags we want to add + var tags []Tag + if err := tx.Where("id IN (?)", tagIDs).Find(&tags).Error; err != nil { + tx.Rollback() + log.Error("Failed to fetch tags: %v", err) + http.Error(w, "Failed to fetch tags", http.StatusInternalServerError) + return + } + + // Verify we found all requested tags + if len(tags) != len(tagIDs) { + tx.Rollback() + log.Error("Not all tags were found. Requested: %d, Found: %d", len(tagIDs), len(tags)) + http.Error(w, "One or more tags not found", http.StatusBadRequest) + return + } + + // Direct SQL approach to insert associations + for _, tag := range tags { + // Check if association already exists using count + var count int64 + err := tx.Table("item_tags"). + Where("item_id = ? AND tag_id = ?", item.ID, tag.ID). + Count(&count).Error + + if err != nil { + tx.Rollback() + log.Error("Failed to check existing association: %v", err) + http.Error(w, "Failed to check existing association", http.StatusInternalServerError) + return + } + + if count == 0 { + // Insert new association + err = tx.Exec("INSERT INTO item_tags (item_id, tag_id) VALUES (?, ?)", + item.ID, tag.ID).Error + if err != nil { + tx.Rollback() + log.Error("Failed to insert tag association: %v", err) + http.Error(w, "Failed to add tag association", http.StatusInternalServerError) + return + } + log.Info("Successfully added tag %d to item %s", tag.ID, itemID) + } else { + log.Info("Tag %d already associated with item %s", tag.ID, itemID) + } + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + log.Error("Failed to commit tag additions: %v", err) + http.Error(w, "Failed to add tags", http.StatusInternalServerError) + return + } + + // Fetch updated item with tags + var updatedItem Item + if err := db.Preload("Tags").First(&updatedItem, itemID).Error; err != nil { + log.Error("Failed to fetch updated item: %v", err) + http.Error(w, "Failed to fetch updated item", http.StatusInternalServerError) + return + } + + log.DatabaseAction("update", fmt.Sprintf("Added tags %v to item %s", tagIDs, itemID)) + json.NewEncoder(w).Encode(updatedItem) +} + +// RemoveItemTagHandler removes a tag from an item +func RemoveItemTagHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + vars := mux.Vars(r) + itemID := vars["id"] + tagID := vars["tagId"] + + log.Info("Attempting to remove tag %s from item %s", tagID, itemID) + + // Begin transaction + tx := db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Delete the association directly + if err := tx.Exec("DELETE FROM item_tags WHERE item_id = ? AND tag_id = ?", + itemID, tagID).Error; err != nil { + tx.Rollback() + log.Error("Failed to remove tag %s from item %s: %v", tagID, itemID, err) + http.Error(w, "Failed to remove tag", http.StatusInternalServerError) + return + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + log.Error("Failed to commit tag removal: %v", err) + http.Error(w, "Failed to remove tag", http.StatusInternalServerError) + return + } + + // Fetch updated item with remaining tags + var updatedItem Item + if err := db.Preload("Tags").First(&updatedItem, itemID).Error; err != nil { + log.Error("Failed to fetch updated item: %v", err) + http.Error(w, "Failed to fetch updated item", http.StatusInternalServerError) + return + } + + log.DatabaseAction("update", fmt.Sprintf("Removed tag %s from item %s", tagID, itemID)) + json.NewEncoder(w).Encode(updatedItem) +} + +// GetItemsByTagHandler returns all items with a specific tag +func GetItemsByTagHandler(w http.ResponseWriter, r *http.Request) { + log := GetLogger() + vars := mux.Vars(r) + tagID := vars["id"] + + var items []Item + if err := db.Joins("JOIN item_tags ON items.id = item_tags.item_id"). + Where("item_tags.tag_id = ?", tagID). + Preload("Tags"). // Always preload tags for items + Find(&items).Error; err != nil { + log.Error("Failed to fetch items by tag: %v", err) + http.Error(w, "Failed to fetch items", http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(items) +}