resolved issues with tag system and API
This commit is contained in:
parent
4d9d722940
commit
8a620576b9
|
@ -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 <token>`
|
||||||
|
- Tokens expire after 24 hours
|
||||||
|
|
||||||
* All endpoints (except `/login`) require a valid JWT token in the `Authorization` header, formatted as `Bearer <token>`.
|
## Endpoints
|
||||||
* To obtain a token, send a POST request to `/login` with the following JSON payload:
|
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
- **URL**: `/login`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Auth Required**: No
|
||||||
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"username": "your_username",
|
"username": "string",
|
||||||
"password": "your_password"
|
"password": "string"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- **Success Response**:
|
||||||
* Successful login will return a JSON response with the token:
|
- **Code**: 200
|
||||||
|
- **Content**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "your_jwt_token"
|
"token": "string"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- **Error Responses**:
|
||||||
|
- **Code**: 401 UNAUTHORIZED
|
||||||
|
- **Content**: "Invalid username or password"
|
||||||
|
|
||||||
**Endpoints:**
|
### Boxes
|
||||||
|
|
||||||
**1. Boxes:**
|
|
||||||
|
|
||||||
* **GET /boxes:**
|
|
||||||
* Returns a list of all boxes.
|
|
||||||
* Response: Array of Box objects
|
|
||||||
|
|
||||||
|
#### Get All Boxes
|
||||||
|
- **URL**: `/boxes`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Auth Required**: Yes
|
||||||
|
- **Success Response**:
|
||||||
|
- **Code**: 200
|
||||||
|
- **Content**:
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"ID": "number",
|
||||||
"name": "Kitchen"
|
"CreatedAt": "timestamp",
|
||||||
},
|
"UpdatedAt": "timestamp",
|
||||||
{
|
"DeletedAt": "timestamp|null",
|
||||||
"id": 2,
|
"name": "string"
|
||||||
"name": "Bedroom"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
* **POST /boxes:**
|
#### Create Box
|
||||||
* Creates a new box.
|
- **URL**: `/boxes`
|
||||||
* Request body: JSON object with the box name
|
- **Method**: `POST`
|
||||||
|
- **Auth Required**: Yes
|
||||||
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "New Box"
|
"name": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Success Response**:
|
||||||
|
- **Code**: 200
|
||||||
|
- **Content**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"id": 3,
|
"ID": "number",
|
||||||
"name": "New Box"
|
"CreatedAt": "timestamp",
|
||||||
|
"UpdatedAt": "timestamp",
|
||||||
|
"DeletedAt": "timestamp|null",
|
||||||
|
"name": "string"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- **Error Response**:
|
||||||
|
- **Code**: 404 NOT FOUND
|
||||||
|
- **Content**: "Box not found"
|
||||||
|
|
||||||
* **DELETE /boxes/{id}:**
|
#### Delete Box
|
||||||
* Deletes the box with the specified ID.
|
- **URL**: `/boxes/{id}`
|
||||||
* Response: 204 No Content
|
- **Method**: `DELETE`
|
||||||
|
- **Auth Required**: Yes
|
||||||
|
- **Success Response**:
|
||||||
|
- **Code**: 204 NO CONTENT
|
||||||
|
- **Error Response**:
|
||||||
|
- **Code**: 404 NOT FOUND
|
||||||
|
- **Content**: "Box not found"
|
||||||
|
|
||||||
**2. Items:**
|
### Items
|
||||||
|
|
||||||
* **GET /items:**
|
|
||||||
* Returns a list of all items.
|
|
||||||
* Response: Array of Item objects
|
|
||||||
|
|
||||||
|
#### Get All Items
|
||||||
|
- **URL**: `/items`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Auth Required**: Yes
|
||||||
|
- **Success Response**:
|
||||||
|
- **Code**: 200
|
||||||
|
- **Content**:
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": 1,
|
"ID": "number",
|
||||||
"name": "Fork",
|
"CreatedAt": "timestamp",
|
||||||
"description": "Silverware",
|
"UpdatedAt": "timestamp",
|
||||||
"box_id": 1,
|
"DeletedAt": "timestamp|null",
|
||||||
"image_path": "path/to/image.jpg"
|
"name": "string",
|
||||||
},
|
"description": "string",
|
||||||
|
"box_id": "number",
|
||||||
|
"image_path": "string",
|
||||||
|
"tags": [
|
||||||
{
|
{
|
||||||
"id": 2,
|
"ID": "number",
|
||||||
"name": "Pillow",
|
"name": "string",
|
||||||
"description": "Fluffy",
|
"description": "string",
|
||||||
"box_id": 2,
|
"color": "string"
|
||||||
"image_path": "path/to/another_image.png"
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
* **POST /items:**
|
#### Create Item
|
||||||
* Creates a new item.
|
- **URL**: `/items`
|
||||||
* Request body: JSON object with item details
|
- **Method**: `POST`
|
||||||
|
- **Auth Required**: Yes
|
||||||
|
- **Request Body**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Spoon",
|
"name": "string",
|
||||||
"description": "For soup",
|
"description": "string",
|
||||||
"box_id": 1,
|
"box_id": "number"
|
||||||
"image_path": "path/to/spoon_image.jpg"
|
}
|
||||||
|
```
|
||||||
|
- **Success Response**:
|
||||||
|
- **Code**: 200
|
||||||
|
- **Content**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"id": 3,
|
"ID": "number",
|
||||||
"name": "Spoon"
|
"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}:**
|
## Error Responses
|
||||||
* Retrieves the item with the specified ID.
|
All endpoints may return these common errors:
|
||||||
* Response: Item object
|
- **401 Unauthorized**: Missing or invalid authentication token
|
||||||
* **GET /boxes/{id}/items:**
|
- **500 Internal Server Error**: Server-side error
|
||||||
* 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
|
|
88
db.go
88
db.go
|
@ -13,6 +13,21 @@ type Box struct {
|
||||||
Name string `json:"name"`
|
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
|
// Define the Item model
|
||||||
type Item struct {
|
type Item struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
|
@ -20,6 +35,7 @@ type Item struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BoxID uint `json:"box_id"`
|
BoxID uint `json:"box_id"`
|
||||||
ImagePath string `json:"image_path"`
|
ImagePath string `json:"image_path"`
|
||||||
|
Tags []Tag `json:"tags" gorm:"many2many:item_tags;"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the User model
|
// Define the User model
|
||||||
|
@ -30,82 +46,18 @@ type User struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectDB establishes a connection to the database and sets up the schema
|
|
||||||
func ConnectDB(dbPath string) (*gorm.DB, error) {
|
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)
|
db, err := gorm.Open("sqlite3", dbPath)
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable detailed SQL logging if we're in DEBUG mode
|
// set auto_vacuum mode to ON
|
||||||
if log != nil && log.GetLogLevel() == "DEBUG" {
|
// this automagically removes old rows from the database when idle
|
||||||
db.LogMode(true)
|
db.Exec("PRAGMA auto_vacuum = ON;")
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AutoMigrate will create the tables if they don't exist
|
// AutoMigrate will create the tables if they don't exist
|
||||||
if err := autoMigrateSchema(db); err != nil {
|
db.AutoMigrate(&Box{}, &Item{}, &User{}, &Tag{}, &ItemTag{})
|
||||||
if log != nil {
|
|
||||||
log.Error("Schema migration failed: %v", err)
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if log != nil {
|
|
||||||
log.Info("Database schema migration completed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
return db, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -16,7 +16,12 @@ func GetItemsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Info("Received %s request to %s", r.Method, r.URL)
|
log.Info("Received %s request to %s", r.Method, r.URL)
|
||||||
|
|
||||||
var items []Item
|
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)
|
json.NewEncoder(w).Encode(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,7 +175,7 @@ func GetItemHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
id := vars["id"]
|
id := vars["id"]
|
||||||
|
|
||||||
var item Item
|
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)
|
log.Warn("Item not found with ID: %s", id)
|
||||||
http.Error(w, "Item not found", http.StatusNotFound)
|
http.Error(w, "Item not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
@ -185,7 +190,7 @@ func GetItemsInBoxHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
id := vars["id"]
|
id := vars["id"]
|
||||||
|
|
||||||
var items []Item
|
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)
|
log.Warn("Failed to fetch items for box ID: %s", id)
|
||||||
http.Error(w, "Items not found", http.StatusNotFound)
|
http.Error(w, "Items not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|
387
main.go
387
main.go
|
@ -13,8 +13,9 @@ import (
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Global variables
|
||||||
var (
|
var (
|
||||||
db *gorm.DB // Declare db globally
|
db *gorm.DB
|
||||||
config *Config
|
config *Config
|
||||||
JWTSecret *[]byte
|
JWTSecret *[]byte
|
||||||
ImageStorage *string
|
ImageStorage *string
|
||||||
|
@ -22,165 +23,113 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Load configuration first
|
||||||
// Load configuration
|
|
||||||
var err error
|
var err error
|
||||||
config, err = loadAndValidateConfig()
|
config, err = loadAndValidateConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up logging BEFORE logging config details
|
// Initialize logger with config values
|
||||||
if err := setupLogging(config.LogFile); err != nil {
|
logger, err := Initialize(config.LogFile, "both") // "both" means log to both file and stdout
|
||||||
log.Fatalf("Failed to set up logging: %v", err)
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize logger: %v", err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
|
||||||
|
// Set up logging configuration
|
||||||
|
if err := setupLogging(config.LogLevel); err != nil {
|
||||||
|
logger.Error("Failed to set up logging: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log := GetLogger()
|
// Log configuration details
|
||||||
log.Printf("Config loaded successfully in main(), DB path %s\n", config.DatabasePath)
|
|
||||||
|
|
||||||
// Now that logging is set up, log the config details
|
|
||||||
logConfigDetails(config)
|
logConfigDetails(config)
|
||||||
|
|
||||||
// Connect to the database
|
// Connect to database
|
||||||
db, err = connectToDatabase(config)
|
db, err = connectToDatabase(config)
|
||||||
if err != nil {
|
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()
|
defer db.Close()
|
||||||
|
|
||||||
// Create routers
|
// Create and configure the router
|
||||||
baseRouter := mux.NewRouter()
|
router := createRouter()
|
||||||
baseRouter.Use(loggingMiddleware)
|
|
||||||
apiRouter := createAPIRouter(baseRouter)
|
|
||||||
staticRouter := createStaticRouter(baseRouter, config.StaticFilesDir)
|
|
||||||
|
|
||||||
// Set up routes
|
|
||||||
setupAPIRoutes(apiRouter)
|
|
||||||
setupStaticRoutes(staticRouter, config.StaticFilesDir)
|
|
||||||
|
|
||||||
// Create CORS handler
|
// Create CORS handler
|
||||||
corsHandler := createCORSHandler(config.AllowedOrigins)
|
corsHandler := createCORSHandler(config.AllowedOrigins)
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
startServer(config.ListeningPort, corsHandler(baseRouter))
|
startServer(config.ListeningPort, corsHandler(router))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAndValidateConfig() (*Config, error) {
|
func createRouter() *mux.Router {
|
||||||
configFile := os.Getenv("BOXES_API_CONFIG")
|
baseRouter := mux.NewRouter()
|
||||||
if configFile == "" {
|
baseRouter.Use(loggingMiddleware)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the database path
|
// API routes should be registered first with a strict prefix match
|
||||||
if config.DatabasePath == "" {
|
apiRouter := baseRouter.PathPrefix("/api/v1").Subrouter()
|
||||||
return nil, fmt.Errorf("database path is not set in config")
|
apiRouter.StrictSlash(true) // This ensures /api/v1/ and /api/v1 are treated the same
|
||||||
}
|
setupAPIRoutes(apiRouter)
|
||||||
DatabasePath = &config.DatabasePath
|
|
||||||
|
|
||||||
// Set JWTSecret
|
// Static file serving should be last and only match if no API routes matched
|
||||||
jwtSecretBytes := []byte(config.JWTSecret)
|
staticRouter := baseRouter.NewRoute().Subrouter()
|
||||||
JWTSecret = &jwtSecretBytes
|
setupStaticRoutes(staticRouter, config.StaticFilesDir)
|
||||||
|
|
||||||
ImageStorage = &config.ImageStorageDir
|
// Add a catch-all NotFoundHandler to the base router
|
||||||
|
baseRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
return config, nil
|
// 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)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func logConfigDetails(config *Config) {
|
|
||||||
log := GetLogger()
|
|
||||||
if log == nil {
|
|
||||||
log.Warn("Warning: Logger not initialized when attempting to log config details")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Otherwise serve the index.html for client-side routing
|
||||||
|
http.ServeFile(w, r, filepath.Join(config.StaticFilesDir, "index.html"))
|
||||||
|
})
|
||||||
|
|
||||||
log.Info("Configuration loaded:")
|
return baseRouter
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupAPIRoutes(router *mux.Router) {
|
func setupAPIRoutes(router *mux.Router) {
|
||||||
|
// Public routes
|
||||||
router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS")
|
router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
protected := router.NewRoute().Subrouter()
|
protected := router.NewRoute().Subrouter()
|
||||||
protected.Use(AuthMiddleware)
|
protected.Use(AuthMiddleware)
|
||||||
|
|
||||||
|
// Box routes
|
||||||
protected.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET", "OPTIONS")
|
protected.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET", "OPTIONS")
|
||||||
protected.Handle("/boxes", http.HandlerFunc(CreateBoxHandler)).Methods("POST", "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(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(GetItemsHandler)).Methods("GET", "OPTIONS")
|
||||||
protected.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST", "OPTIONS")
|
protected.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST", "OPTIONS")
|
||||||
protected.Handle("/items/{id}", http.HandlerFunc(GetItemHandler)).Methods("GET", "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(UpdateItemHandler)).Methods("PUT", "OPTIONS")
|
||||||
protected.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE", "OPTIONS")
|
protected.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE", "OPTIONS")
|
||||||
protected.Handle("/items/{id}/image", http.HandlerFunc(GetItemImageHandler)).Methods("GET", "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("/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) {
|
func setupManagementRoutes(router *mux.Router) {
|
||||||
|
@ -193,76 +142,175 @@ func setupManagementRoutes(router *mux.Router) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupStaticRoutes(router *mux.Router, staticPath string) {
|
func setupStaticRoutes(router *mux.Router, staticPath string) {
|
||||||
customHandler := createCustomStaticHandler(staticPath)
|
// Ensure static directory exists
|
||||||
router.PathPrefix("/").Handler(http.StripPrefix("/", customHandler))
|
if err := validateStaticDirectory(staticPath); err != nil {
|
||||||
|
log.Printf("Warning: Static directory validation failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCustomStaticHandler(staticPath string) http.HandlerFunc {
|
fileServer := http.FileServer(http.Dir(staticPath))
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath := filepath.Join(staticPath, r.URL.Path)
|
// Only serve static files for paths that don't start with /api
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if log != nil {
|
logger := GetLogger()
|
||||||
log.Warn("File not found: %s", fullPath)
|
|
||||||
}
|
// Don't serve static files for API paths
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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 {
|
func loadAndValidateConfig() (*Config, error) {
|
||||||
origins := strings.Split(allowedOrigins, ",")
|
configFile := os.Getenv("BOXES_API_CONFIG")
|
||||||
if len(origins) == 0 {
|
if configFile == "" {
|
||||||
origins = []string{"http://localhost:3000"}
|
configFile = "config.yaml" // Default config path
|
||||||
}
|
}
|
||||||
|
|
||||||
return cors.New(cors.Options{
|
var err error
|
||||||
AllowedOrigins: origins,
|
config, err := LoadConfig(configFile)
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
if err != nil {
|
||||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
return nil, fmt.Errorf("failed to load config: %v", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServer(port int, handler http.Handler) {
|
// Set global variables
|
||||||
log := GetLogger()
|
jwtSecretBytes := []byte(config.JWTSecret)
|
||||||
if log != nil {
|
JWTSecret = &jwtSecretBytes
|
||||||
log.Info("Server starting on port %d", port)
|
ImageStorage = &config.ImageStorageDir
|
||||||
}
|
DatabasePath = &config.DatabasePath
|
||||||
log.Info("Server listening on port %d\n", port)
|
|
||||||
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), handler))
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateStaticDirectory(path string) error {
|
func setupLogging(logLevel string) error {
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
logger := GetLogger()
|
||||||
return fmt.Errorf("static directory does not exist: %s", path)
|
if logger == nil {
|
||||||
|
return fmt.Errorf("logger not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if logLevel != "" {
|
||||||
|
if err := logger.SetLogLevel(logLevel); err != nil {
|
||||||
|
return fmt.Errorf("failed to set log level: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
func logConfigDetails(config *Config) {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
logger := GetLogger()
|
||||||
username := "anonymous"
|
if logger == nil {
|
||||||
if user, ok := r.Context().Value(userKey).(string); ok {
|
return
|
||||||
username = user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a response wrapper to capture the status code
|
logger.Info("Configuration loaded:")
|
||||||
rw := &responseWriter{w, http.StatusOK}
|
logger.Info("Database Path: %s", config.DatabasePath)
|
||||||
|
logger.Info("Image Storage Dir: %s", config.ImageStorageDir)
|
||||||
next.ServeHTTP(rw, r)
|
logger.Info("Log File: %s", config.LogFile)
|
||||||
|
logger.Info("Log Level: %s", config.LogLevel)
|
||||||
if log := GetLogger(); log != nil {
|
logger.Info("Listening Port: %d", config.ListeningPort)
|
||||||
log.HTTPRequest(r.Method, r.URL.Path, username, rw.statusCode)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
type responseWriter struct {
|
||||||
|
@ -274,3 +322,22 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||||
rw.statusCode = code
|
rw.statusCode = code
|
||||||
rw.ResponseWriter.WriteHeader(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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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 <method> -e <endpoint> [-i <id>] [-d <data>]"
|
||||||
|
echo " -m <method> HTTP method (GET, POST, PUT, DELETE)"
|
||||||
|
echo " -e <endpoint> API endpoint (e.g., /tags/1/items)"
|
||||||
|
echo " -i <id> Optional element ID (appended to endpoint)"
|
||||||
|
echo " -d <data> 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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
Loading…
Reference in New Issue