commit ebeae34e01d0379bb1e8034a735fb7b97f79c0ca Author: ianshaloom Date: Fri Nov 21 08:50:27 2025 +0300 Add initial project structure with core functionality for book and stationery uploads - Created main application entry point in `main.go`. - Added configuration management in `config/config.go` and tests in `config/config_test.go`. - Implemented handlers for book and stationery uploads in `handlers/book.go` and `handlers/stationery.go`, including validation logic. - Established database connection and services in `services/database.go` and `services/book_service.go`. - Defined models for books and stationery in `models/book.go` and `models/stationery.go`. - Set up Firebase integration for image uploads in `services/firebase.go`. - Created migration scripts for database schema in `migrations/001_create_tables.sql` and subsequent updates. - Added CORS and error handling middleware. - Included comprehensive tests for handlers, services, and utilities. - Documented API endpoints and usage in `README.md` and migration instructions in `migrations/README.md`. - Introduced `.gitignore` to exclude unnecessary files and directories. - Added Go module support with `go.mod` and `go.sum` files. - Implemented utility functions for slug generation and validation in `utils/slug.go` and `utils/validation.go`. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a271b25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +jd-book-uploader + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Environment variables +.env + +# Firebase credentials +firebase-service-account.json +*.json +!go.mod +!go.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Build artifacts +dist/ +build/ + +.env.example +.env.production +.env.local +.env.production.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..f29b674 --- /dev/null +++ b/README.md @@ -0,0 +1,490 @@ +# JD Book Uploader - Backend API Documentation + +## Overview + +Backend API for uploading book and stationery products with images to Firebase Storage and storing metadata in PostgreSQL. + +## Base URL + +- **Development**: `http://localhost:8080` +- **Production**: Configure via `FRONTEND_URL` environment variable + +--- + +## API Endpoints + +### 1. Health Check + +**Endpoint:** `GET /api/health` + +**Description:** Check the health status of the API and its dependencies (database and Firebase). + +**Request:** +- **Method:** `GET` +- **Path:** `/api/health` +- **Headers:** None required +- **Body:** None + +**Response:** + +**Success (200 OK):** +```json +{ + "status": "ok", + "database": "connected", + "firebase": "connected" +} +``` + +**Service Unavailable (503):** +```json +{ + "status": "degraded", + "database": "disconnected", + "firebase": "connected" +} +``` + +**Response Fields:** +- `status` (string): `"ok"` if all services connected, `"degraded"` if any service unavailable +- `database` (string): `"connected"`, `"disconnected"`, or `"not_initialized"` +- `firebase` (string): `"connected"`, `"disconnected"`, or `"not_initialized"` + +--- + +### 2. Upload Book + +**Endpoint:** `POST /api/books` + +**Description:** Upload a book product with cover image. Image is uploaded to Firebase Storage and book metadata is stored in PostgreSQL. + +**Request:** +- **Method:** `POST` +- **Path:** `/api/books` +- **Content-Type:** `multipart/form-data` +- **Body:** FormData with the following fields: + +**Required Parameters:** +- `image` (File) - Book cover image file + - **Type:** File (multipart/form-data) + - **Allowed types:** `image/png`, `image/jpeg`, `image/jpg`, `image/webp` + - **Max size:** 10MB + - **Required:** Yes + +- `book_name` (string) - Book name + - **Type:** String + - **Max length:** 200 characters + - **Required:** Yes + - **Validation:** Not empty + +- `cost` (number) - Cost price + - **Type:** Number (decimal) + - **Required:** Yes + - **Validation:** Must be a positive number + +- `price` (number) - Selling price + - **Type:** Number (decimal) + - **Required:** Yes + - **Validation:** Must be a positive number + +- `quantity` (integer) - Stock quantity + - **Type:** Integer + - **Required:** Yes + - **Validation:** Must be a positive integer + +- `publisher_author` (string) - Publisher or author name + - **Type:** String + - **Max length:** 200 characters + - **Required:** Yes + - **Validation:** Not empty + +- `category` (string) - Book category + - **Type:** String + - **Max length:** 100 characters + - **Required:** Yes + - **Validation:** Not empty + +**Optional Parameters:** +- `discount` (number) - Discount amount + - **Type:** Number (decimal) + - **Default:** 0 + - **Required:** No + - **Validation:** Must be non-negative (>= 0) + +- `description` (string) - Book description + - **Type:** String + - **Max length:** 1000 characters + - **Required:** No + +**Request Body Format:** +``` +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... + +------WebKitFormBoundary... +Content-Disposition: form-data; name="image"; filename="book-cover.png" +Content-Type: image/png + +[binary image data] +------WebKitFormBoundary... +Content-Disposition: form-data; name="book_name" + +The Great Gatsby +------WebKitFormBoundary... +Content-Disposition: form-data; name="cost" + +15.50 +------WebKitFormBoundary... +Content-Disposition: form-data; name="price" + +24.99 +------WebKitFormBoundary... +Content-Disposition: form-data; name="discount" + +0 +------WebKitFormBoundary... +Content-Disposition: form-data; name="quantity" + +100 +------WebKitFormBoundary... +Content-Disposition: form-data; name="publisher_author" + +F. Scott Fitzgerald +------WebKitFormBoundary... +Content-Disposition: form-data; name="category" + +Fiction +------WebKitFormBoundary... +Content-Disposition: form-data; name="description" + +A classic American novel +------WebKitFormBoundary...-- +``` + +**Success Response (201 Created):** +```json +{ + "success": true, + "data": { + "book_code": "550e8400-e29b-41d4-a716-446655440000", + "book_name": "The Great Gatsby", + "image_url": "https://storage.googleapis.com/download/storage/v1/b/kaisa-341a6.appspot.com/o/images%2F2025%2F11%2F1732123456_abc123.png?generation=1732123456789&alt=media", + "slug": "the-great-gatsby", + "created_at": "2025-11-20T17:30:00Z" + } +} +``` + +**Response Fields:** +- `success` (boolean): Always `true` on success +- `data` (object): Contains the created book data + - `book_code` (string): UUID generated for the book + - `book_name` (string): Book name as provided + - `image_url` (string): Public URL of uploaded image in Firebase Storage + - `slug` (string): URL-friendly slug generated from book name + - `created_at` (string): ISO 8601 timestamp of creation + +**Error Responses:** + +**400 Bad Request** - Validation error: +```json +{ + "success": false, + "error": "book_name is required" +} +``` + +**413 Payload Too Large** - File size exceeds limit: +```json +{ + "success": false, + "error": "File size exceeds 10MB limit" +} +``` + +**500 Internal Server Error** - Server error: +```json +{ + "success": false, + "error": "Failed to upload image" +} +``` + +**Common Error Messages:** +- `"Image file is required"` - No image file provided +- `"Invalid file type. Only PNG, JPEG, and WEBP are allowed"` - Unsupported file type +- `"File size exceeds 10MB limit"` - File too large +- `"book_name is required"` - Missing required field +- `"Invalid cost value"` - Invalid number format or negative value +- `"Failed to parse form data"` - Malformed request +- `"Failed to upload image"` - Firebase upload error +- `"Failed to create book"` - Database insert error + +--- + +### 3. Upload Stationery + +**Endpoint:** `POST /api/stationery` + +**Description:** Upload a stationery product with image. Image is uploaded to Firebase Storage and stationery metadata is stored in PostgreSQL. + +**Request:** +- **Method:** `POST` +- **Path:** `/api/stationery` +- **Content-Type:** `multipart/form-data` +- **Body:** FormData with the following fields: + +**Required Parameters:** +- `image` (File) - Stationery image file + - **Type:** File (multipart/form-data) + - **Allowed types:** `image/png`, `image/jpeg`, `image/jpg`, `image/webp` + - **Max size:** 10MB + - **Required:** Yes + +- `stationery_name` (string) - Stationery name + - **Type:** String + - **Max length:** 200 characters + - **Required:** Yes + - **Validation:** Not empty + +- `cost` (number) - Cost price + - **Type:** Number (decimal) + - **Required:** Yes + - **Validation:** Must be a positive number + +- `price` (number) - Selling price + - **Type:** Number (decimal) + - **Required:** Yes + - **Validation:** Must be a positive number + +- `quantity` (integer) - Stock quantity + - **Type:** Integer + - **Required:** Yes + - **Validation:** Must be a positive integer + +- `category` (string) - Stationery category + - **Type:** String + - **Max length:** 100 characters + - **Required:** Yes + - **Validation:** Not empty + +**Optional Parameters:** +- `discount` (number) - Discount amount + - **Type:** Number (decimal) + - **Default:** 0 + - **Required:** No + - **Validation:** Must be non-negative (>= 0) + +- `color` (string) - Product color + - **Type:** String + - **Required:** No + +- `material` (string) - Product material + - **Type:** String + - **Required:** No + +- `dimensions` (string) - Product dimensions + - **Type:** String + - **Required:** No + +- `description` (string) - Product description + - **Type:** String + - **Max length:** 1000 characters + - **Required:** No + +**Request Body Format:** +``` +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... + +------WebKitFormBoundary... +Content-Disposition: form-data; name="image"; filename="pen.png" +Content-Type: image/png + +[binary image data] +------WebKitFormBoundary... +Content-Disposition: form-data; name="stationery_name" + +Blue Ballpoint Pen +------WebKitFormBoundary... +Content-Disposition: form-data; name="cost" + +2.50 +------WebKitFormBoundary... +Content-Disposition: form-data; name="price" + +5.99 +------WebKitFormBoundary... +Content-Disposition: form-data; name="discount" + +0 +------WebKitFormBoundary... +Content-Disposition: form-data; name="quantity" + +200 +------WebKitFormBoundary... +Content-Disposition: form-data; name="category" + +Writing +------WebKitFormBoundary... +Content-Disposition: form-data; name="color" + +Blue +------WebKitFormBoundary... +Content-Disposition: form-data; name="material" + +Plastic +------WebKitFormBoundary... +Content-Disposition: form-data; name="dimensions" + +15cm +------WebKitFormBoundary... +Content-Disposition: form-data; name="description" + +Smooth writing ballpoint pen +------WebKitFormBoundary...-- +``` + +**Success Response (201 Created):** +```json +{ + "success": true, + "data": { + "stationery_code": "550e8400-e29b-41d4-a716-446655440001", + "stationery_name": "Blue Ballpoint Pen", + "image_url": "https://storage.googleapis.com/download/storage/v1/b/kaisa-341a6.appspot.com/o/images%2F2025%2F11%2F1732123456_def456.png?generation=1732123456789&alt=media", + "slug": "blue-ballpoint-pen", + "created_at": "2025-11-20T17:30:00Z" + } +} +``` + +**Response Fields:** +- `success` (boolean): Always `true` on success +- `data` (object): Contains the created stationery data + - `stationery_code` (string): UUID generated for the stationery item + - `stationery_name` (string): Stationery name as provided + - `image_url` (string): Public URL of uploaded image in Firebase Storage + - `slug` (string): URL-friendly slug generated from stationery name + - `created_at` (string): ISO 8601 timestamp of creation + +**Error Responses:** + +**400 Bad Request** - Validation error: +```json +{ + "success": false, + "error": "stationery_name is required" +} +``` + +**413 Payload Too Large** - File size exceeds limit: +```json +{ + "success": false, + "error": "File size exceeds 10MB limit" +} +``` + +**500 Internal Server Error** - Server error: +```json +{ + "success": false, + "error": "Failed to upload image" +} +``` + +**Common Error Messages:** +- `"Image file is required"` - No image file provided +- `"Invalid file type. Only PNG, JPEG, and WEBP are allowed"` - Unsupported file type +- `"File size exceeds 10MB limit"` - File too large +- `"stationery_name is required"` - Missing required field +- `"Invalid cost value"` - Invalid number format or negative value +- `"Failed to parse form data"` - Malformed request +- `"Failed to upload image"` - Firebase upload error +- `"Failed to create stationery"` - Database insert error + +--- + +## Response Format + +All API responses follow a consistent structure: + +**Success Response:** +```json +{ + "success": true, + "data": { ... } +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": "Error message" +} +``` + +--- + +## HTTP Status Codes + +- **200 OK** - Health check successful +- **201 Created** - Resource created successfully (book/stationery uploaded) +- **400 Bad Request** - Validation error or invalid input +- **413 Payload Too Large** - File size exceeds 10MB limit +- **500 Internal Server Error** - Server error (database, Firebase, or internal error) +- **503 Service Unavailable** - Service dependencies (database or Firebase) unavailable + +--- + +## Field Validation Summary + +### Book Upload Fields + +| Field | Type | Required | Max Length | Validation Rules | +|-------|------|----------|------------|------------------| +| `image` | File | Yes | 10MB | PNG, JPEG, or WEBP only | +| `book_name` | string | Yes | 200 | Not empty | +| `cost` | number | Yes | - | Positive number (> 0) | +| `price` | number | Yes | - | Positive number (> 0) | +| `discount` | number | No | - | Non-negative (>= 0), default: 0 | +| `quantity` | integer | Yes | - | Positive integer (> 0) | +| `publisher_author` | string | Yes | 200 | Not empty | +| `category` | string | Yes | 100 | Not empty | +| `description` | string | No | 1000 | - | + +### Stationery Upload Fields + +| Field | Type | Required | Max Length | Validation Rules | +|-------|------|----------|------------|------------------| +| `image` | File | Yes | 10MB | PNG, JPEG, or WEBP only | +| `stationery_name` | string | Yes | 200 | Not empty | +| `cost` | number | Yes | - | Positive number (> 0) | +| `price` | number | Yes | - | Positive number (> 0) | +| `discount` | number | No | - | Non-negative (>= 0), default: 0 | +| `quantity` | integer | Yes | - | Positive integer (> 0) | +| `category` | string | Yes | 100 | Not empty | +| `color` | string | No | - | - | +| `material` | string | No | - | - | +| `dimensions` | string | No | - | - | +| `description` | string | No | 1000 | - | + +--- + +## Notes + +1. **Content-Type Header**: For file uploads, use `multipart/form-data`. Do NOT manually set the `Content-Type` header - let the browser/client set it automatically with the proper boundary. + +2. **Image Processing**: The backend expects images to be processed on the frontend (resized to 1000x1000, background removed) before upload. Upload the processed PNG image. + +3. **Auto-Generated Fields**: The following fields are automatically generated by the backend: + - `book_code` / `stationery_code`: UUID v4 + - `slug`: Generated from product name (lowercase, spaces to hyphens, special characters removed) + - `created_at`: Current timestamp + - `updated_at`: Current timestamp + +4. **File Upload**: Only one image file per request. The file field name must be `image`. + +5. **Error Messages**: Always check the `error` field in error responses for user-friendly error messages. + +6. **CORS**: The backend is configured to accept requests from the frontend URL specified in `FRONTEND_URL` environment variable (default: `http://localhost:5173`). diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..2112e2c --- /dev/null +++ b/config/config.go @@ -0,0 +1,107 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +// Config holds all application configuration +type Config struct { + // Server + Port string + FrontendURL string + + // Database + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + + // Firebase + FirebaseProjectID string + FirebaseStorageBucket string + FirebaseCredentialsFile string +} + +// LoadConfig loads configuration from environment variables +// Loading order (later files override earlier ones): +// 1. .env (base/default values) +// 2. .env.local (local development overrides - takes precedence) +// Note: OS environment variables take highest precedence (godotenv won't override them) +func LoadConfig() (*Config, error) { + // Load .env file if it exists (optional, won't error if missing) + _ = godotenv.Load(".env") + + // Load .env.local if it exists (overrides .env values) + // This allows local development overrides without modifying .env + _ = godotenv.Load(".env.local") + + cfg := &Config{ + // Server + Port: getEnv("PORT", "8080"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:5173"), + + // Database + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnv("DB_PORT", "5432"), + DBUser: getEnv("DB_USER", ""), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", ""), + + // Firebase + FirebaseProjectID: getEnv("FIREBASE_PROJECT_ID", ""), + FirebaseStorageBucket: getEnv("FIREBASE_STORAGE_BUCKET", ""), + FirebaseCredentialsFile: getEnv("FIREBASE_CREDENTIALS_FILE", ""), + } + + // Validate required fields + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return cfg, nil +} + +// Validate checks that all required configuration values are set +func (c *Config) Validate() error { + var missing []string + + if c.DBUser == "" { + missing = append(missing, "DB_USER") + } + if c.DBPassword == "" { + missing = append(missing, "DB_PASSWORD") + } + if c.DBName == "" { + missing = append(missing, "DB_NAME") + } + if c.FirebaseProjectID == "" { + missing = append(missing, "FIREBASE_PROJECT_ID") + } + if c.FirebaseStorageBucket == "" { + missing = append(missing, "FIREBASE_STORAGE_BUCKET") + } + if c.FirebaseCredentialsFile == "" { + // Check if GOOGLE_APPLICATION_CREDENTIALS is set as fallback + if os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") == "" { + missing = append(missing, "FIREBASE_CREDENTIALS_FILE (or GOOGLE_APPLICATION_CREDENTIALS)") + } + } + + if len(missing) > 0 { + return fmt.Errorf("missing required environment variables: %v", missing) + } + + return nil +} + +// getEnv gets an environment variable or returns a default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..a97ee91 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,205 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoadConfig(t *testing.T) { + // Save original env values + originalEnv := make(map[string]string) + envVars := []string{ + "PORT", "FRONTEND_URL", + "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME", + "FIREBASE_PROJECT_ID", "FIREBASE_STORAGE_BUCKET", "FIREBASE_CREDENTIALS_FILE", + } + for _, key := range envVars { + originalEnv[key] = os.Getenv(key) + os.Unsetenv(key) + } + defer func() { + // Restore original env values + for key, value := range originalEnv { + if value != "" { + os.Setenv(key, value) + } else { + os.Unsetenv(key) + } + } + }() + + tests := []struct { + name string + setup func() + wantErr bool + }{ + { + name: "valid config", + setup: func() { + os.Setenv("DB_USER", "test_user") + os.Setenv("DB_PASSWORD", "test_password") + os.Setenv("DB_NAME", "test_db") + os.Setenv("FIREBASE_PROJECT_ID", "test-project") + os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com") + os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json") + }, + wantErr: false, + }, + { + name: "missing DB_USER", + setup: func() { + os.Setenv("DB_PASSWORD", "test_password") + os.Setenv("DB_NAME", "test_db") + os.Setenv("FIREBASE_PROJECT_ID", "test-project") + os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com") + os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json") + }, + wantErr: true, + }, + { + name: "missing FIREBASE_PROJECT_ID", + setup: func() { + os.Setenv("DB_USER", "test_user") + os.Setenv("DB_PASSWORD", "test_password") + os.Setenv("DB_NAME", "test_db") + os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com") + os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json") + }, + wantErr: true, + }, + { + name: "uses defaults for optional fields", + setup: func() { + os.Setenv("DB_USER", "test_user") + os.Setenv("DB_PASSWORD", "test_password") + os.Setenv("DB_NAME", "test_db") + os.Setenv("FIREBASE_PROJECT_ID", "test-project") + os.Setenv("FIREBASE_STORAGE_BUCKET", "test-bucket.appspot.com") + os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test-credentials.json") + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up env before each test + for _, key := range envVars { + os.Unsetenv(key) + } + + tt.setup() + + cfg, err := LoadConfig() + if (err != nil) != tt.wantErr { + t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && cfg == nil { + t.Error("LoadConfig() returned nil config without error") + return + } + + if !tt.wantErr { + // Verify defaults are used + if cfg.Port == "" { + t.Error("Port should have default value") + } + if cfg.DBHost == "" { + t.Error("DBHost should have default value") + } + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + }{ + { + name: "valid config", + config: &Config{ + DBUser: "user", + DBPassword: "password", + DBName: "dbname", + FirebaseProjectID: "project-id", + FirebaseStorageBucket: "bucket.appspot.com", + FirebaseCredentialsFile: "./credentials.json", + }, + wantErr: false, + }, + { + name: "missing DB_USER", + config: &Config{ + DBPassword: "password", + DBName: "dbname", + FirebaseProjectID: "project-id", + FirebaseStorageBucket: "bucket.appspot.com", + FirebaseCredentialsFile: "./credentials.json", + }, + wantErr: true, + }, + { + name: "missing FIREBASE_PROJECT_ID", + config: &Config{ + DBUser: "user", + DBPassword: "password", + DBName: "dbname", + FirebaseStorageBucket: "bucket.appspot.com", + FirebaseCredentialsFile: "./credentials.json", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLoadConfig_EnvFilePrecedence(t *testing.T) { + // This test verifies that .env.local overrides .env values + // Note: This is a conceptual test - actual file loading would require test files + + // Save original env + originalPort := os.Getenv("PORT") + defer func() { + if originalPort != "" { + os.Setenv("PORT", originalPort) + } else { + os.Unsetenv("PORT") + } + }() + + // Set required vars + os.Setenv("DB_USER", "test") + os.Setenv("DB_PASSWORD", "test") + os.Setenv("DB_NAME", "test") + os.Setenv("FIREBASE_PROJECT_ID", "test") + os.Setenv("FIREBASE_STORAGE_BUCKET", "test.appspot.com") + os.Setenv("FIREBASE_CREDENTIALS_FILE", "./test.json") + + // Test that LoadConfig works + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg == nil { + t.Fatal("LoadConfig() returned nil") + } + + // Verify it uses default PORT if not set + if cfg.Port == "" { + t.Error("Port should have default value") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb29338 --- /dev/null +++ b/go.mod @@ -0,0 +1,79 @@ +module jd-book-uploader + +go 1.25.0 + +require ( + firebase.google.com/go/v4 v4.18.0 + github.com/gofiber/fiber/v2 v2.52.10 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + google.golang.org/api v0.247.0 +) + +require ( + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go v0.121.6 // indirect + cloud.google.com/go/auth v0.16.5 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/firestore v1.18.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/longrunning v0.7.0 // indirect + cloud.google.com/go/monitoring v1.24.2 // indirect + cloud.google.com/go/storage v1.57.2 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/zeebo/errs v1.4.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.74.3 // indirect + google.golang.org/protobuf v1.36.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6d038fe --- /dev/null +++ b/go.sum @@ -0,0 +1,266 @@ +cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= +cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= +cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= +cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= +cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= +cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= +cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw= +cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA= +cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4= +cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk= +cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= +cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw= +firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= +google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.74.3 h1:Upn9dMUIfuKB8AGEIdaAx21wDy1z/hV+Z3s5SScLkI4= +google.golang.org/grpc v1.74.3/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/book.go b/handlers/book.go new file mode 100644 index 0000000..99ca735 --- /dev/null +++ b/handlers/book.go @@ -0,0 +1,237 @@ +package handlers + +import ( + "fmt" + "io" + "log" + "strconv" + + "jd-book-uploader/models" + "jd-book-uploader/services" + "jd-book-uploader/utils" + + "github.com/gofiber/fiber/v2" +) + +// UploadBook handles book upload requests +func UploadBook(c *fiber.Ctx) error { + ctx := c.Context() + + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Failed to parse form data", + }) + } + + // Extract image file + imageFiles := form.File["image"] + if len(imageFiles) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Image file is required", + }) + } + + imageFile := imageFiles[0] + + // Validate file type + allowedTypes := map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/jpg": true, + "image/webp": true, + } + + if !allowedTypes[imageFile.Header.Get("Content-Type")] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid file type. Only PNG, JPEG, and WEBP are allowed", + }) + } + + // Validate file size (max 10MB) + maxSize := int64(10 * 1024 * 1024) // 10MB + if imageFile.Size > maxSize { + return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{ + "success": false, + "error": "File size exceeds 10MB limit", + }) + } + + // Read image file + file, err := imageFile.Open() + if err != nil { + log.Printf("Failed to open image file: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to read image file", + }) + } + defer file.Close() + + imageData, err := io.ReadAll(file) + if err != nil { + log.Printf("Failed to read image data: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to read image data", + }) + } + + // Extract form fields + bookName := c.FormValue("book_name") + costStr := c.FormValue("cost") + priceStr := c.FormValue("price") + discountStr := c.FormValue("discount") + quantityStr := c.FormValue("quantity") + publisherAuthor := c.FormValue("publisher_author") + category := c.FormValue("category") + description := c.FormValue("description") + + // Validate required fields + if err := validateBookFields(bookName, costStr, priceStr, quantityStr, publisherAuthor, category); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": err.Error(), + }) + } + + // Parse numeric fields + cost, err := strconv.ParseFloat(costStr, 64) + if err != nil || cost <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid cost value", + }) + } + + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil || price <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid price value", + }) + } + + discount := 0.0 + if discountStr != "" { + discount, err = strconv.ParseFloat(discountStr, 64) + if err != nil || discount < 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid discount value", + }) + } + } + + quantity, err := strconv.Atoi(quantityStr) + if err != nil || quantity <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid quantity value", + }) + } + + // Generate slug from book name + slug := utils.GenerateSlug(utils.CleanString(bookName)) + + // Check if slug already exists + slugExists, err := services.BookSlugExists(ctx, slug) + if err != nil { + log.Printf("Failed to check slug existence: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to validate product", + }) + } + + if slugExists { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "success": false, + "error": fmt.Sprintf("A book with slug '%s' already exists", slug), + }) + } + + // Prepare filename: slug.png + filename := fmt.Sprintf("%s.png", slug) + + // Upload image to Firebase Storage with correct path + imageURL, err := services.UploadImage(ctx, imageData, "/jd-bookshop/books", filename) + if err != nil { + log.Printf("Firebase upload failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to upload image", + }) + } + + // Create book request + bookReq := &models.BookCreateRequest{ + BookName: utils.CleanString(bookName), + Cost: cost, + Price: price, + Discount: discount, + Quantity: quantity, + PublisherAuthor: utils.CleanString(publisherAuthor), + Category: utils.CleanString(category), + Description: utils.CleanString(description), + ImageURL: imageURL, + } + + // Create book in database + book, err := services.CreateBook(ctx, bookReq) + if err != nil { + log.Printf("Database insert failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to create book", + }) + } + + // Return success response + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "success": true, + "data": fiber.Map{ + "book_code": book.BookCode, + "book_name": book.BookName, + "image_url": book.ImageURL, + "slug": book.Slug, + "created_at": book.CreatedAt, + }, + }) +} + +// validateBookFields validates required book fields +func validateBookFields(bookName, cost, price, quantity, publisherAuthor, category string) error { + if bookName == "" { + return fmt.Errorf("book_name is required") + } + if len(bookName) > 200 { + return fmt.Errorf("book_name exceeds 200 characters") + } + if cost == "" { + return fmt.Errorf("cost is required") + } + if price == "" { + return fmt.Errorf("price is required") + } + if quantity == "" { + return fmt.Errorf("quantity is required") + } + if publisherAuthor == "" { + return fmt.Errorf("publisher_author is required") + } + if len(publisherAuthor) > 200 { + return fmt.Errorf("publisher_author exceeds 200 characters") + } + if category == "" { + return fmt.Errorf("category is required") + } + if len(category) > 100 { + return fmt.Errorf("category exceeds 100 characters") + } + return nil +} diff --git a/handlers/book_test.go b/handlers/book_test.go new file mode 100644 index 0000000..21cf871 --- /dev/null +++ b/handlers/book_test.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "bytes" + "mime/multipart" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestUploadBook(t *testing.T) { + // This test requires database and Firebase to be initialized + // Skip if not available + t.Skip("Skipping UploadBook test - requires database and Firebase") + + app := fiber.New() + app.Post("/api/books", UploadBook) + + // Create multipart form with test data + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add form fields + writer.WriteField("book_name", "Test Book") + writer.WriteField("cost", "10.50") + writer.WriteField("price", "15.99") + writer.WriteField("quantity", "100") + writer.WriteField("publisher_author", "Test Publisher") + writer.WriteField("category", "Fiction") + + // Add image file + part, _ := writer.CreateFormFile("image", "test.png") + part.Write([]byte("fake image data")) + + writer.Close() + + req := httptest.NewRequest("POST", "/api/books", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusCreated { + t.Errorf("Expected status %d, got %d", fiber.StatusCreated, resp.StatusCode) + } +} + +func TestUploadBook_ValidationErrors(t *testing.T) { + app := fiber.New() + app.Post("/api/books", UploadBook) + + // Test missing required field + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("book_name", "") // Empty book name + writer.Close() + + req := httptest.NewRequest("POST", "/api/books", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusBadRequest { + t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode) + } +} diff --git a/handlers/health.go b/handlers/health.go new file mode 100644 index 0000000..2b80b02 --- /dev/null +++ b/handlers/health.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v2" + "jd-book-uploader/services" +) + +// HealthCheck handles health check requests +func HealthCheck(c *fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + status := fiber.Map{ + "status": "ok", + } + + // Check database connection + if services.DB != nil { + err := services.DB.Ping(ctx) + if err != nil { + status["database"] = "disconnected" + status["status"] = "degraded" + return c.Status(fiber.StatusServiceUnavailable).JSON(status) + } + status["database"] = "connected" + } else { + status["database"] = "not_initialized" + status["status"] = "degraded" + return c.Status(fiber.StatusServiceUnavailable).JSON(status) + } + + // Check Firebase connection + if services.FirebaseClient != nil { + // Try to get bucket to verify connection + bucket, err := services.FirebaseClient.DefaultBucket() + if err != nil || bucket == nil { + status["firebase"] = "disconnected" + status["status"] = "degraded" + return c.Status(fiber.StatusServiceUnavailable).JSON(status) + } + status["firebase"] = "connected" + } else { + status["firebase"] = "not_initialized" + status["status"] = "degraded" + return c.Status(fiber.StatusServiceUnavailable).JSON(status) + } + + return c.JSON(status) +} diff --git a/handlers/health_test.go b/handlers/health_test.go new file mode 100644 index 0000000..c775fda --- /dev/null +++ b/handlers/health_test.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestHealthCheck(t *testing.T) { + app := fiber.New() + app.Get("/health", HealthCheck) + + req := httptest.NewRequest("GET", "/health", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusOK && resp.StatusCode != fiber.StatusServiceUnavailable { + t.Errorf("Expected status %d or %d, got %d", fiber.StatusOK, fiber.StatusServiceUnavailable, resp.StatusCode) + } +} diff --git a/handlers/stationery.go b/handlers/stationery.go new file mode 100644 index 0000000..29f49b9 --- /dev/null +++ b/handlers/stationery.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "fmt" + "io" + "log" + "strconv" + + "github.com/gofiber/fiber/v2" + "jd-book-uploader/models" + "jd-book-uploader/services" + "jd-book-uploader/utils" +) + +// UploadStationery handles stationery upload requests +func UploadStationery(c *fiber.Ctx) error { + ctx := c.Context() + + // Parse multipart form + form, err := c.MultipartForm() + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Failed to parse form data", + }) + } + + // Extract image file + imageFiles := form.File["image"] + if len(imageFiles) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Image file is required", + }) + } + + imageFile := imageFiles[0] + + // Validate file type + allowedTypes := map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/jpg": true, + "image/webp": true, + } + + if !allowedTypes[imageFile.Header.Get("Content-Type")] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid file type. Only PNG, JPEG, and WEBP are allowed", + }) + } + + // Validate file size (max 10MB) + maxSize := int64(10 * 1024 * 1024) // 10MB + if imageFile.Size > maxSize { + return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{ + "success": false, + "error": "File size exceeds 10MB limit", + }) + } + + // Read image file + file, err := imageFile.Open() + if err != nil { + log.Printf("Failed to open image file: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to read image file", + }) + } + defer file.Close() + + imageData, err := io.ReadAll(file) + if err != nil { + log.Printf("Failed to read image data: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to read image data", + }) + } + + // Extract form fields + stationeryName := c.FormValue("stationery_name") + costStr := c.FormValue("cost") + priceStr := c.FormValue("price") + discountStr := c.FormValue("discount") + quantityStr := c.FormValue("quantity") + color := c.FormValue("color") + material := c.FormValue("material") + dimensions := c.FormValue("dimensions") + category := c.FormValue("category") + description := c.FormValue("description") + + // Validate required fields + if err := validateStationeryFields(stationeryName, costStr, priceStr, quantityStr, category); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": err.Error(), + }) + } + + // Parse numeric fields + cost, err := strconv.ParseFloat(costStr, 64) + if err != nil || cost <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid cost value", + }) + } + + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil || price <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid price value", + }) + } + + discount := 0.0 + if discountStr != "" { + discount, err = strconv.ParseFloat(discountStr, 64) + if err != nil || discount < 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid discount value", + }) + } + } + + quantity, err := strconv.Atoi(quantityStr) + if err != nil || quantity <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "success": false, + "error": "Invalid quantity value", + }) + } + + // Generate slug from stationery name + slug := utils.GenerateSlug(utils.CleanString(stationeryName)) + + // Check if slug already exists + slugExists, err := services.StationerySlugExists(ctx, slug) + if err != nil { + log.Printf("Failed to check slug existence: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to validate product", + }) + } + + if slugExists { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "success": false, + "error": fmt.Sprintf("A stationery item with slug '%s' already exists", slug), + }) + } + + // Prepare filename: slug.png + filename := fmt.Sprintf("%s.png", slug) + + // Upload image to Firebase Storage with correct path + imageURL, err := services.UploadImage(ctx, imageData, "/jd-bookshop/stationery", filename) + if err != nil { + log.Printf("Firebase upload failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to upload image", + }) + } + + // Create stationery request + stationeryReq := &models.StationeryCreateRequest{ + StationeryName: utils.CleanString(stationeryName), + Cost: cost, + Price: price, + Discount: discount, + Quantity: quantity, + Color: utils.CleanString(color), + Material: utils.CleanString(material), + Dimensions: utils.CleanString(dimensions), + Category: utils.CleanString(category), + Description: utils.CleanString(description), + ImageURL: imageURL, + } + + // Create stationery in database + stationery, err := services.CreateStationery(ctx, stationeryReq) + if err != nil { + log.Printf("Database insert failed: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Failed to create stationery", + }) + } + + // Return success response + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "success": true, + "data": fiber.Map{ + "stationery_code": stationery.StationeryCode, + "stationery_name": stationery.StationeryName, + "image_url": stationery.ImageURL, + "slug": stationery.Slug, + "created_at": stationery.CreatedAt, + }, + }) +} + +// validateStationeryFields validates required stationery fields +func validateStationeryFields(stationeryName, cost, price, quantity, category string) error { + if stationeryName == "" { + return fmt.Errorf("stationery_name is required") + } + if len(stationeryName) > 200 { + return fmt.Errorf("stationery_name exceeds 200 characters") + } + if cost == "" { + return fmt.Errorf("cost is required") + } + if price == "" { + return fmt.Errorf("price is required") + } + if quantity == "" { + return fmt.Errorf("quantity is required") + } + if category == "" { + return fmt.Errorf("category is required") + } + if len(category) > 100 { + return fmt.Errorf("category exceeds 100 characters") + } + return nil +} diff --git a/handlers/stationery_test.go b/handlers/stationery_test.go new file mode 100644 index 0000000..1c1333e --- /dev/null +++ b/handlers/stationery_test.go @@ -0,0 +1,72 @@ +package handlers + +import ( + "bytes" + "mime/multipart" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestUploadStationery(t *testing.T) { + // This test requires database and Firebase to be initialized + // Skip if not available + t.Skip("Skipping UploadStationery test - requires database and Firebase") + + app := fiber.New() + app.Post("/api/stationery", UploadStationery) + + // Create multipart form with test data + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add form fields + writer.WriteField("stationery_name", "Test Pen") + writer.WriteField("cost", "2.50") + writer.WriteField("price", "5.99") + writer.WriteField("quantity", "200") + writer.WriteField("category", "Writing") + writer.WriteField("color", "Blue") + + // Add image file + part, _ := writer.CreateFormFile("image", "test.png") + part.Write([]byte("fake image data")) + + writer.Close() + + req := httptest.NewRequest("POST", "/api/stationery", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusCreated { + t.Errorf("Expected status %d, got %d", fiber.StatusCreated, resp.StatusCode) + } +} + +func TestUploadStationery_ValidationErrors(t *testing.T) { + app := fiber.New() + app.Post("/api/stationery", UploadStationery) + + // Test missing required field + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("stationery_name", "") // Empty stationery name + writer.Close() + + req := httptest.NewRequest("POST", "/api/stationery", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusBadRequest { + t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..11bc0f2 --- /dev/null +++ b/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "jd-book-uploader/config" + "jd-book-uploader/handlers" + "jd-book-uploader/middleware" + "jd-book-uploader/services" + + "github.com/gofiber/fiber/v2" +) + +func main() { + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize database connection + _, err = services.NewDBPool(cfg) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer services.CloseDB() + log.Println("Database connected successfully") + + // Initialize Firebase + _, err = services.InitFirebase(cfg) + if err != nil { + log.Fatalf("Failed to initialize Firebase: %v", err) + } + log.Println("Firebase initialized successfully") + + // Create Fiber app + app := fiber.New(fiber.Config{ + AppName: "JD Book Uploader API", + }) + + // Register middleware + app.Use(middleware.RecoverHandler()) // Recover from panics + app.Use(middleware.ErrorHandler) // Global error handler + app.Use(middleware.SetupCORS(cfg)) // CORS + app.Use(middleware.SetupLogger()) // Request logging + + // Register routes + api := app.Group("/api") + api.Get("/health", handlers.HealthCheck) + api.Post("/books", handlers.UploadBook) + api.Post("/stationery", handlers.UploadStationery) + + // Start server + port := cfg.Port + if port == "" { + port = "8080" + } + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("Server starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Fatalf("Server failed to start: %v", err) + } + }() + + <-c + log.Println("Gracefully shutting down...") + + // Close database connection + if err := services.CloseDB(); err != nil { + log.Printf("Error closing database: %v", err) + } + + // Shutdown Fiber app + if err := app.Shutdown(); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } + + log.Println("Server stopped") +} diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..6f7f42b --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "jd-book-uploader/config" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" +) + +// SetupCORS configures CORS middleware based on application config +func SetupCORS(cfg *config.Config) fiber.Handler { + return cors.New(cors.Config{ + AllowOrigins: cfg.FrontendURL, + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization", + AllowCredentials: true, + ExposeHeaders: "Content-Length", + MaxAge: 3600, // 1 hour + }) +} diff --git a/middleware/cors_test.go b/middleware/cors_test.go new file mode 100644 index 0000000..a9d5152 --- /dev/null +++ b/middleware/cors_test.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "jd-book-uploader/config" +) + +func TestSetupCORS(t *testing.T) { + cfg := &config.Config{ + FrontendURL: "http://localhost:5173", + } + + app := fiber.New() + app.Use(SetupCORS(cfg)) + + app.Get("/test", func(c *fiber.Ctx) error { + return c.SendString("OK") + }) + + // Test CORS preflight request + req := httptest.NewRequest("OPTIONS", "/test", nil) + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Access-Control-Request-Method", "GET") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusNoContent { + t.Errorf("Expected status %d, got %d", fiber.StatusNoContent, resp.StatusCode) + } + + // Check CORS headers + allowOrigin := resp.Header.Get("Access-Control-Allow-Origin") + if allowOrigin != "http://localhost:5173" { + t.Errorf("Expected Access-Control-Allow-Origin %s, got %s", "http://localhost:5173", allowOrigin) + } +} diff --git a/middleware/error_handler.go b/middleware/error_handler.go new file mode 100644 index 0000000..399792e --- /dev/null +++ b/middleware/error_handler.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "log" + + "github.com/gofiber/fiber/v2" +) + +// ErrorHandler is a global error handler middleware +func ErrorHandler(c *fiber.Ctx) error { + // Call next middleware/handler + err := c.Next() + + // If no error, return + if err == nil { + return nil + } + + // Log error with context + log.Printf("Error: %v | Method: %s | Path: %s | IP: %s", + err, + c.Method(), + c.Path(), + c.IP(), + ) + + // Check if error is a Fiber error + code := fiber.StatusInternalServerError + message := "Internal Server Error" + + if e, ok := err.(*fiber.Error); ok { + code = e.Code + message = e.Message + } + + // Return structured error response + return c.Status(code).JSON(fiber.Map{ + "success": false, + "error": message, + }) +} + +// RecoverHandler recovers from panics and returns a proper error response +func RecoverHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + defer func() { + if r := recover(); r != nil { + // Log panic with context + log.Printf("Panic recovered: %v | Method: %s | Path: %s | IP: %s", + r, + c.Method(), + c.Path(), + c.IP(), + ) + + // Return error response + c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "success": false, + "error": "Internal Server Error", + }) + } + }() + + return c.Next() + } +} diff --git a/middleware/error_handler_test.go b/middleware/error_handler_test.go new file mode 100644 index 0000000..7b7db1b --- /dev/null +++ b/middleware/error_handler_test.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "errors" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestErrorHandler(t *testing.T) { + app := fiber.New() + app.Use(ErrorHandler) + + app.Get("/error", func(c *fiber.Ctx) error { + return fiber.NewError(fiber.StatusBadRequest, "Bad Request") + }) + + app.Get("/panic", func(c *fiber.Ctx) error { + return errors.New("some error") + }) + + // Test Fiber error + req := httptest.NewRequest("GET", "/error", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusBadRequest { + t.Errorf("Expected status %d, got %d", fiber.StatusBadRequest, resp.StatusCode) + } + + // Test generic error + req = httptest.NewRequest("GET", "/panic", nil) + resp, err = app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusInternalServerError { + t.Errorf("Expected status %d, got %d", fiber.StatusInternalServerError, resp.StatusCode) + } +} + +func TestRecoverHandler(t *testing.T) { + app := fiber.New() + app.Use(RecoverHandler()) + + app.Get("/panic", func(c *fiber.Ctx) error { + panic("test panic") + }) + + req := httptest.NewRequest("GET", "/panic", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Test request failed: %v", err) + } + + if resp.StatusCode != fiber.StatusInternalServerError { + t.Errorf("Expected status %d, got %d", fiber.StatusInternalServerError, resp.StatusCode) + } +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 0000000..f7f9db7 --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "log" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +// SetupLogger configures request logging middleware +func SetupLogger() fiber.Handler { + return logger.New(logger.Config{ + Format: "${time} ${status} - ${latency} ${method} ${path} ${ip}\n", + TimeFormat: "2006-01-02 15:04:05", + TimeZone: "Local", + Output: nil, // Use default (stdout) + }) +} + +// CustomLogger is a more detailed logger with structured output +func CustomLogger() fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + + // Process request + err := c.Next() + + // Calculate latency + latency := time.Since(start) + + // Log request details + log.Printf("[%s] %s %s | Status: %d | Latency: %v | IP: %s", + time.Now().Format("2006-01-02 15:04:05"), + c.Method(), + c.Path(), + c.Response().StatusCode(), + latency, + c.IP(), + ) + + return err + } +} diff --git a/migrations/001_create_tables.sql b/migrations/001_create_tables.sql new file mode 100644 index 0000000..9242b31 --- /dev/null +++ b/migrations/001_create_tables.sql @@ -0,0 +1,46 @@ +-- Create books table +CREATE TABLE IF NOT EXISTS books ( + book_code UUID PRIMARY KEY DEFAULT gen_random_uuid(), + book_name VARCHAR(200) NOT NULL, + cost DECIMAL(10,2) NOT NULL, + price DECIMAL(10,2) NOT NULL, + discount DECIMAL(10,2) DEFAULT 0, + quantity INTEGER NOT NULL, + publisher_author VARCHAR(200) NOT NULL, + category VARCHAR(100) NOT NULL, + description TEXT, + image_url TEXT NOT NULL, + slug VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for books table +CREATE INDEX IF NOT EXISTS idx_books_created_at ON books(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_books_category ON books(category); +CREATE INDEX IF NOT EXISTS idx_books_slug ON books(slug); + +-- Create stationery table +CREATE TABLE IF NOT EXISTS stationery ( + stationery_code UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stationery_name VARCHAR(200) NOT NULL, + cost DECIMAL(10,2) NOT NULL, + price DECIMAL(10,2) NOT NULL, + discount DECIMAL(10,2) DEFAULT 0, + quantity INTEGER NOT NULL, + color VARCHAR(100), + material VARCHAR(100), + dimensions VARCHAR(100), + category VARCHAR(100) NOT NULL, + description TEXT, + image_url TEXT NOT NULL, + slug VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for stationery table +CREATE INDEX IF NOT EXISTS idx_stationery_created_at ON stationery(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_stationery_category ON stationery(category); +CREATE INDEX IF NOT EXISTS idx_stationery_slug ON stationery(slug); + diff --git a/migrations/002_fix_books_table.sql b/migrations/002_fix_books_table.sql new file mode 100644 index 0000000..d9d6e8a --- /dev/null +++ b/migrations/002_fix_books_table.sql @@ -0,0 +1,25 @@ +-- Drop the old books table if it exists with wrong schema +DROP TABLE IF EXISTS books CASCADE; + +-- Recreate books table with correct schema +CREATE TABLE books ( + book_code UUID PRIMARY KEY DEFAULT gen_random_uuid(), + book_name VARCHAR(200) NOT NULL, + cost DECIMAL(10,2) NOT NULL, + price DECIMAL(10,2) NOT NULL, + discount DECIMAL(10,2) DEFAULT 0, + quantity INTEGER NOT NULL, + publisher_author VARCHAR(200) NOT NULL, + category VARCHAR(100) NOT NULL, + description TEXT, + image_url TEXT NOT NULL, + slug VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for books table +CREATE INDEX idx_books_created_at ON books(created_at DESC); +CREATE INDEX idx_books_category ON books(category); +CREATE INDEX idx_books_slug ON books(slug); + diff --git a/migrations/003_add_unique_slug_constraints.sql b/migrations/003_add_unique_slug_constraints.sql new file mode 100644 index 0000000..fb31c9d --- /dev/null +++ b/migrations/003_add_unique_slug_constraints.sql @@ -0,0 +1,43 @@ +-- Add UNIQUE constraint to slug columns in both books and stationery tables +-- This prevents duplicate slugs and enables slug-based image filename uniqueness + +-- First, handle any existing duplicate slugs by appending a suffix +-- For books +UPDATE books b1 +SET slug = slug || '-' || book_code::text +WHERE EXISTS ( + SELECT 1 FROM books b2 + WHERE b2.slug = b1.slug + AND b2.book_code < b1.book_code +); + +-- For stationery +UPDATE stationery s1 +SET slug = slug || '-' || stationery_code::text +WHERE EXISTS ( + SELECT 1 FROM stationery s2 + WHERE s2.slug = s1.slug + AND s2.stationery_code < s1.stationery_code +); + +-- Add unique constraint to books.slug (if not exists) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'books_slug_unique' + ) THEN + ALTER TABLE books ADD CONSTRAINT books_slug_unique UNIQUE (slug); + END IF; +END $$; + +-- Add unique constraint to stationery.slug (if not exists) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'stationery_slug_unique' + ) THEN + ALTER TABLE stationery ADD CONSTRAINT stationery_slug_unique UNIQUE (slug); + END IF; +END $$; diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..ed19d05 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,41 @@ +# Database Migrations + +## Running Migrations + +### Prerequisites +1. Ensure PostgreSQL is running +2. Create the database if it doesn't exist: + ```bash + psql -U postgres -c "CREATE DATABASE jd_book_uploader;" + ``` +3. Ensure your `.env` or `.env.local` file in the backend directory has the correct database credentials + +### Run Migration + +```bash +cd backend +./scripts/run_migration.sh +``` + +Or manually: +```bash +cd backend +export $(grep -v '^#' .env.local | grep -v '^$' | xargs) +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f migrations/001_create_tables.sql +``` + +### Verify Schema + +```bash +cd backend +./scripts/verify_schema.sh +``` + +This will show: +- Table structures for `books` and `stationery` +- All indexes created on both tables + +## Migration Files + +- `001_create_tables.sql` - Creates books and stationery tables with indexes + diff --git a/models/book.go b/models/book.go new file mode 100644 index 0000000..369055a --- /dev/null +++ b/models/book.go @@ -0,0 +1,33 @@ +package models + +import "time" + +// Book represents a book product in the database +type Book struct { + BookCode string `json:"book_code" db:"book_code"` + BookName string `json:"book_name" db:"book_name"` + Cost float64 `json:"cost" db:"cost"` + Price float64 `json:"price" db:"price"` + Discount float64 `json:"discount" db:"discount"` + Quantity int `json:"quantity" db:"quantity"` + PublisherAuthor string `json:"publisher_author" db:"publisher_author"` + Category string `json:"category" db:"category"` + Description string `json:"description" db:"description"` + ImageURL string `json:"image_url" db:"image_url"` + Slug string `json:"slug" db:"slug"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// BookCreateRequest represents the data needed to create a new book +type BookCreateRequest struct { + BookName string `json:"book_name"` + Cost float64 `json:"cost"` + Price float64 `json:"price"` + Discount float64 `json:"discount"` + Quantity int `json:"quantity"` + PublisherAuthor string `json:"publisher_author"` + Category string `json:"category"` + Description string `json:"description"` + ImageURL string `json:"image_url"` +} diff --git a/models/stationery.go b/models/stationery.go new file mode 100644 index 0000000..a19e599 --- /dev/null +++ b/models/stationery.go @@ -0,0 +1,37 @@ +package models + +import "time" + +// Stationery represents a stationery product in the database +type Stationery struct { + StationeryCode string `json:"stationery_code" db:"stationery_code"` + StationeryName string `json:"stationery_name" db:"stationery_name"` + Cost float64 `json:"cost" db:"cost"` + Price float64 `json:"price" db:"price"` + Discount float64 `json:"discount" db:"discount"` + Quantity int `json:"quantity" db:"quantity"` + Color string `json:"color" db:"color"` + Material string `json:"material" db:"material"` + Dimensions string `json:"dimensions" db:"dimensions"` + Category string `json:"category" db:"category"` + Description string `json:"description" db:"description"` + ImageURL string `json:"image_url" db:"image_url"` + Slug string `json:"slug" db:"slug"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// StationeryCreateRequest represents the data needed to create a new stationery item +type StationeryCreateRequest struct { + StationeryName string `json:"stationery_name"` + Cost float64 `json:"cost"` + Price float64 `json:"price"` + Discount float64 `json:"discount"` + Quantity int `json:"quantity"` + Color string `json:"color"` + Material string `json:"material"` + Dimensions string `json:"dimensions"` + Category string `json:"category"` + Description string `json:"description"` + ImageURL string `json:"image_url"` +} diff --git a/project.toml b/project.toml new file mode 100644 index 0000000..53674a0 --- /dev/null +++ b/project.toml @@ -0,0 +1,9 @@ +[build] + # Paketo buildpacks will auto-detect Go version from go.mod + # No need to specify GO_VERSION + +[metadata] + include_files = [ + "**/*" + ] + diff --git a/scripts/run_migration.sh b/scripts/run_migration.sh new file mode 100755 index 0000000..03ac255 --- /dev/null +++ b/scripts/run_migration.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Load environment variables from .env or .env.local +if [ -f .env ]; then + export $(grep -v '^#' .env | grep -v '^$' | xargs) +elif [ -f .env.local ]; then + export $(grep -v '^#' .env.local | grep -v '^$' | xargs) +else + echo "Error: No .env or .env.local file found" + exit 1 +fi + +# Check if required variables are set +if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then + echo "Error: Required database environment variables not set" + echo "Required: DB_HOST, DB_PORT, DB_USER, DB_NAME" + exit 1 +fi + +# Run migration +echo "Running database migration..." +echo "Connecting to: $DB_HOST:$DB_PORT/$DB_NAME as $DB_USER" + +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f migrations/001_create_tables.sql + +if [ $? -eq 0 ]; then + echo "Migration completed successfully!" +else + echo "Migration failed!" + exit 1 +fi + diff --git a/scripts/verify_schema.sh b/scripts/verify_schema.sh new file mode 100755 index 0000000..8976b7a --- /dev/null +++ b/scripts/verify_schema.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Load environment variables from .env or .env.local +if [ -f .env ]; then + export $(grep -v '^#' .env | grep -v '^$' | xargs) +elif [ -f .env.local ]; then + export $(grep -v '^#' .env.local | grep -v '^$' | xargs) +else + echo "Error: No .env or .env.local file found" + exit 1 +fi + +# Check if required variables are set +if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then + echo "Error: Required database environment variables not set" + exit 1 +fi + +echo "Verifying database schema..." +echo "Connecting to: $DB_HOST:$DB_PORT/$DB_NAME as $DB_USER" +echo "" + +# Verify books table +echo "=== Checking books table ===" +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "\d books" 2>&1 + +if [ $? -eq 0 ]; then + echo "✓ books table exists" +else + echo "✗ books table not found" +fi + +echo "" + +# Verify stationery table +echo "=== Checking stationery table ===" +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "\d stationery" 2>&1 + +if [ $? -eq 0 ]; then + echo "✓ stationery table exists" +else + echo "✗ stationery table not found" +fi + +echo "" + +# Verify indexes +echo "=== Checking indexes ===" +PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c " +SELECT + tablename, + indexname, + indexdef +FROM pg_indexes +WHERE schemaname = 'public' +AND (tablename = 'books' OR tablename = 'stationery') +ORDER BY tablename, indexname; +" 2>&1 + +echo "" +echo "Schema verification complete!" + diff --git a/services/book_service.go b/services/book_service.go new file mode 100644 index 0000000..bda9282 --- /dev/null +++ b/services/book_service.go @@ -0,0 +1,151 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "jd-book-uploader/models" + "jd-book-uploader/utils" +) + +// CreateBook creates a new book record in the database +func CreateBook(ctx context.Context, req *models.BookCreateRequest) (*models.Book, error) { + if DB == nil { + return nil, fmt.Errorf("database connection not initialized") + } + + // Generate UUID for book_code + bookCode := uuid.New().String() + + // Generate slug from book name + slug := utils.GenerateSlug(req.BookName) + + // Set default discount if not provided + discount := req.Discount + if discount < 0 { + discount = 0 + } + + now := time.Now() + + // Insert book into database + query := ` + INSERT INTO books ( + book_code, book_name, cost, price, discount, quantity, + publisher_author, category, description, image_url, slug, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + ) RETURNING + book_code, book_name, cost, price, discount, quantity, + publisher_author, category, description, image_url, slug, + created_at, updated_at + ` + + var book models.Book + err := DB.QueryRow(ctx, query, + bookCode, + req.BookName, + req.Cost, + req.Price, + discount, + req.Quantity, + req.PublisherAuthor, + req.Category, + req.Description, + req.ImageURL, + slug, + now, + now, + ).Scan( + &book.BookCode, + &book.BookName, + &book.Cost, + &book.Price, + &book.Discount, + &book.Quantity, + &book.PublisherAuthor, + &book.Category, + &book.Description, + &book.ImageURL, + &book.Slug, + &book.CreatedAt, + &book.UpdatedAt, + ) + + if err != nil { + // Check for duplicate key error + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { // unique_violation + return nil, fmt.Errorf("book with this code already exists") + } + } + return nil, fmt.Errorf("failed to create book: %w", err) + } + + return &book, nil +} + +// GetBookByCode retrieves a book by its code +func GetBookByCode(ctx context.Context, bookCode string) (*models.Book, error) { + if DB == nil { + return nil, fmt.Errorf("database connection not initialized") + } + + query := ` + SELECT + book_code, book_name, cost, price, discount, quantity, + publisher_author, category, description, image_url, slug, + created_at, updated_at + FROM books + WHERE book_code = $1 + ` + + var book models.Book + err := DB.QueryRow(ctx, query, bookCode).Scan( + &book.BookCode, + &book.BookName, + &book.Cost, + &book.Price, + &book.Discount, + &book.Quantity, + &book.PublisherAuthor, + &book.Category, + &book.Description, + &book.ImageURL, + &book.Slug, + &book.CreatedAt, + &book.UpdatedAt, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("book not found") + } + return nil, fmt.Errorf("failed to get book: %w", err) + } + + return &book, nil +} + +// BookSlugExists checks if a slug already exists in the books table +func BookSlugExists(ctx context.Context, slug string) (bool, error) { + if DB == nil { + return false, fmt.Errorf("database connection not initialized") + } + + query := `SELECT EXISTS(SELECT 1 FROM books WHERE slug = $1)` + var exists bool + err := DB.QueryRow(ctx, query, slug).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check slug existence: %w", err) + } + + return exists, nil +} diff --git a/services/book_service_test.go b/services/book_service_test.go new file mode 100644 index 0000000..362b8b8 --- /dev/null +++ b/services/book_service_test.go @@ -0,0 +1,82 @@ +package services + +import ( + "context" + "testing" + + "jd-book-uploader/models" +) + +func TestCreateBook(t *testing.T) { + // This test requires a running database + // Skip if not available + t.Skip("Skipping CreateBook test - requires running database") + + ctx := context.Background() + + // This test would require: + // 1. Database connection initialized + // 2. Valid test data + // 3. Cleanup after test + + req := &models.BookCreateRequest{ + BookName: "Test Book", + Cost: 10.50, + Price: 15.99, + Discount: 0, + Quantity: 100, + PublisherAuthor: "Test Publisher", + Category: "Fiction", + Description: "A test book", + ImageURL: "https://example.com/image.png", + } + + book, err := CreateBook(ctx, req) + if err != nil { + t.Fatalf("CreateBook() error = %v", err) + } + + if book == nil { + t.Fatal("CreateBook() returned nil book") + } + + if book.BookCode == "" { + t.Error("CreateBook() did not generate book_code") + } + + if book.Slug == "" { + t.Error("CreateBook() did not generate slug") + } + + if book.Slug != "test-book" { + t.Errorf("CreateBook() slug = %q, want %q", book.Slug, "test-book") + } +} + +func TestGetBookByCode(t *testing.T) { + // This test requires a running database + // Skip if not available + t.Skip("Skipping GetBookByCode test - requires running database") + + ctx := context.Background() + + // This test would require: + // 1. Database connection initialized + // 2. A book already in the database + // 3. Valid book_code + + bookCode := "test-book-code" + book, err := GetBookByCode(ctx, bookCode) + if err != nil { + t.Logf("GetBookByCode() error = %v (expected if book not found)", err) + return + } + + if book == nil { + t.Error("GetBookByCode() returned nil book") + } + + if book.BookCode != bookCode { + t.Errorf("GetBookByCode() book_code = %q, want %q", book.BookCode, bookCode) + } +} diff --git a/services/database.go b/services/database.go new file mode 100644 index 0000000..99b82e9 --- /dev/null +++ b/services/database.go @@ -0,0 +1,85 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "jd-book-uploader/config" +) + +// DB holds the database connection pool +var DB *pgxpool.Pool + +// NewDBPool creates a new database connection pool +func NewDBPool(cfg *config.Config) (*pgxpool.Pool, error) { + // Build connection string + connString := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + cfg.DBHost, + cfg.DBPort, + cfg.DBUser, + cfg.DBPassword, + cfg.DBName, + ) + + // Parse connection string + poolConfig, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, fmt.Errorf("failed to parse connection string: %w", err) + } + + // Configure connection pool + poolConfig.MaxConns = 10 + poolConfig.MinConns = 2 + poolConfig.MaxConnLifetime = time.Hour + poolConfig.MaxConnIdleTime = time.Minute * 30 + poolConfig.HealthCheckPeriod = time.Minute + + // Set connection timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create connection pool + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + DB = pool + return pool, nil +} + +// CloseDB closes the database connection pool +func CloseDB() error { + if DB != nil { + DB.Close() + } + return nil +} + +// RetryConnection attempts to reconnect to the database with retries +func RetryConnection(cfg *config.Config, maxRetries int, retryDelay time.Duration) (*pgxpool.Pool, error) { + var pool *pgxpool.Pool + var err error + + for i := 0; i < maxRetries; i++ { + pool, err = NewDBPool(cfg) + if err == nil { + return pool, nil + } + + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + + return nil, fmt.Errorf("failed to connect after %d attempts: %w", maxRetries, err) +} diff --git a/services/database_test.go b/services/database_test.go new file mode 100644 index 0000000..24b9681 --- /dev/null +++ b/services/database_test.go @@ -0,0 +1,57 @@ +package services + +import ( + "testing" + "time" + + "jd-book-uploader/config" +) + +func TestNewDBPool(t *testing.T) { + // This test requires a running PostgreSQL instance + // Skip if not available + t.Skip("Skipping database connection test - requires running PostgreSQL") + + cfg := &config.Config{ + DBHost: "localhost", + DBPort: "5432", + DBUser: "test_user", + DBPassword: "test_password", + DBName: "test_db", + } + + pool, err := NewDBPool(cfg) + if err != nil { + t.Fatalf("NewDBPool() error = %v", err) + } + defer pool.Close() + + if pool == nil { + t.Error("NewDBPool() returned nil pool") + } +} + +func TestRetryConnection(t *testing.T) { + // This test requires a running PostgreSQL instance + // Skip if not available + t.Skip("Skipping database retry test - requires running PostgreSQL") + + cfg := &config.Config{ + DBHost: "localhost", + DBPort: "5432", + DBUser: "test_user", + DBPassword: "test_password", + DBName: "test_db", + } + + pool, err := RetryConnection(cfg, 3, time.Second) + if err != nil { + t.Logf("RetryConnection() error = %v (expected if DB not available)", err) + return + } + defer pool.Close() + + if pool == nil { + t.Error("RetryConnection() returned nil pool") + } +} diff --git a/services/firebase.go b/services/firebase.go new file mode 100644 index 0000000..da31ca8 --- /dev/null +++ b/services/firebase.go @@ -0,0 +1,141 @@ +package services + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "jd-book-uploader/config" + + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/storage" + "google.golang.org/api/option" +) + +var ( + FirebaseApp *firebase.App + FirebaseClient *storage.Client +) + +// InitFirebase initializes Firebase Admin SDK and Storage client +func InitFirebase(cfg *config.Config) (*storage.Client, error) { + ctx := context.Background() + + // Determine credentials file path + credentialsFile := cfg.FirebaseCredentialsFile + if credentialsFile == "" { + // Fallback to GOOGLE_APPLICATION_CREDENTIALS env var + credentialsFile = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + } + + // Check if credentials file exists + if credentialsFile != "" { + if _, err := os.Stat(credentialsFile); os.IsNotExist(err) { + return nil, fmt.Errorf("firebase credentials file not found: %s", credentialsFile) + } + // Convert relative path to absolute + if !filepath.IsAbs(credentialsFile) { + credentialsFile, _ = filepath.Abs(credentialsFile) + } + } + + // Build Firebase config + firebaseConfig := &firebase.Config{ + ProjectID: cfg.FirebaseProjectID, + StorageBucket: cfg.FirebaseStorageBucket, + } + + // Initialize Firebase app + var app *firebase.App + var err error + + if credentialsFile != "" { + // Use credentials file + opt := option.WithCredentialsFile(credentialsFile) + app, err = firebase.NewApp(ctx, firebaseConfig, opt) + } else { + // Use default credentials (from environment) + app, err = firebase.NewApp(ctx, firebaseConfig) + } + + if err != nil { + return nil, fmt.Errorf("failed to initialize Firebase app: %w", err) + } + + // Create Storage client + client, err := app.Storage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create Firebase Storage client: %w", err) + } + + FirebaseApp = app + FirebaseClient = client + + return client, nil +} + +// UploadImage uploads an image to Firebase Storage and returns the public URL +// folderPath: storage folder path (e.g., "/jd-bookshop/books" or "/jd-bookshop/stationery") +// filename: image filename (should be the slug with .png extension) +func UploadImage(ctx context.Context, imageData []byte, folderPath string, filename string) (string, error) { + if FirebaseClient == nil { + return "", fmt.Errorf("firebase client not initialized") + } + + // Build object path: {folderPath}/{filename} + // Use provided path strictly without adding year/month + objectPath := fmt.Sprintf("%s/%s", folderPath, filename) + + // Get bucket handle + bucket, err := FirebaseClient.DefaultBucket() + if err != nil { + return "", fmt.Errorf("failed to get default bucket: %w", err) + } + + // Create object handle + obj := bucket.Object(objectPath) + + // Create writer + writer := obj.NewWriter(ctx) + writer.ContentType = "image/png" + writer.CacheControl = "public, max-age=31536000" // 1 year cache + + // Write image data with retry logic + maxRetries := 3 + var writeErr error + for i := 0; i < maxRetries; i++ { + if i > 0 { + // Wait before retry (exponential backoff) + time.Sleep(time.Duration(i) * time.Second) + } + + // Write data + _, writeErr = writer.Write(imageData) + if writeErr == nil { + break + } + } + + if writeErr != nil { + writer.Close() + return "", fmt.Errorf("failed to write image data after %d attempts: %w", maxRetries, writeErr) + } + + // Close writer to finalize upload + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to close writer: %w", err) + } + + // Get public URL from Firebase + attrs, err := obj.Attrs(ctx) + if err != nil { + return "", fmt.Errorf("failed to get object attributes: %w", err) + } + + // Use Firebase's original download link (MediaLink) + publicURL := attrs.MediaLink + + return publicURL, nil +} diff --git a/services/firebase_test.go b/services/firebase_test.go new file mode 100644 index 0000000..fa09aa9 --- /dev/null +++ b/services/firebase_test.go @@ -0,0 +1,108 @@ +package services + +import ( + "context" + "os" + "testing" + + "jd-book-uploader/config" +) + +func TestInitFirebase(t *testing.T) { + // Load config from environment + cfg, err := config.LoadConfig() + if err != nil { + t.Skipf("Skipping Firebase test - config not available: %v", err) + return + } + + // Check if credentials file exists + if cfg.FirebaseCredentialsFile == "" && os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") == "" { + t.Skip("Skipping Firebase test - no credentials file configured") + return + } + + // Test Firebase initialization + client, err := InitFirebase(cfg) + if err != nil { + t.Fatalf("InitFirebase() error = %v", err) + } + + if client == nil { + t.Fatal("InitFirebase() returned nil client") + } + + // Verify client can access bucket + ctx := context.Background() + bucket, err := client.DefaultBucket() + if err != nil { + t.Fatalf("Failed to get default bucket: %v", err) + } + + if bucket == nil { + t.Fatal("DefaultBucket() returned nil") + } + + // Test bucket name matches config + bucketAttrs, err := bucket.Attrs(ctx) + if err != nil { + t.Fatalf("Failed to get bucket attributes: %v", err) + } + + expectedBucket := cfg.FirebaseStorageBucket + if bucketAttrs.Name != expectedBucket { + t.Logf("Warning: Bucket name mismatch. Expected: %s, Got: %s", expectedBucket, bucketAttrs.Name) + } + + t.Logf("Firebase initialized successfully. Bucket: %s", bucketAttrs.Name) +} + +func TestUploadImage(t *testing.T) { + // Load config from environment + cfg, err := config.LoadConfig() + if err != nil { + t.Skipf("Skipping Firebase upload test - config not available: %v", err) + return + } + + // Initialize Firebase first + client, err := InitFirebase(cfg) + if err != nil { + t.Skipf("Skipping Firebase upload test - initialization failed: %v", err) + return + } + + if client == nil { + t.Skip("Skipping Firebase upload test - client is nil") + return + } + + ctx := context.Background() + + // Create a small test image (1x1 PNG) + // PNG header + minimal valid PNG data + testImageData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, // IHDR data + 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk + 0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // IDAT data + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND + } + + // Upload test image + url, err := UploadImage(ctx, testImageData, "test", "test-image.png") + if err != nil { + t.Fatalf("UploadImage() error = %v", err) + } + + if url == "" { + t.Fatal("UploadImage() returned empty URL") + } + + t.Logf("Image uploaded successfully. URL: %s", url) + + // Verify URL is accessible (optional - can be skipped if network issues) + // This would require HTTP client to check if URL is accessible +} diff --git a/services/stationery_service.go b/services/stationery_service.go new file mode 100644 index 0000000..a17c109 --- /dev/null +++ b/services/stationery_service.go @@ -0,0 +1,157 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "jd-book-uploader/models" + "jd-book-uploader/utils" +) + +// CreateStationery creates a new stationery record in the database +func CreateStationery(ctx context.Context, req *models.StationeryCreateRequest) (*models.Stationery, error) { + if DB == nil { + return nil, fmt.Errorf("database connection not initialized") + } + + // Generate UUID for stationery_code + stationeryCode := uuid.New().String() + + // Generate slug from stationery name + slug := utils.GenerateSlug(req.StationeryName) + + // Set default discount if not provided + discount := req.Discount + if discount < 0 { + discount = 0 + } + + now := time.Now() + + // Insert stationery into database + query := ` + INSERT INTO stationery ( + stationery_code, stationery_name, cost, price, discount, quantity, + color, material, dimensions, category, description, image_url, slug, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) RETURNING + stationery_code, stationery_name, cost, price, discount, quantity, + color, material, dimensions, category, description, image_url, slug, + created_at, updated_at + ` + + var stationery models.Stationery + err := DB.QueryRow(ctx, query, + stationeryCode, + req.StationeryName, + req.Cost, + req.Price, + discount, + req.Quantity, + req.Color, + req.Material, + req.Dimensions, + req.Category, + req.Description, + req.ImageURL, + slug, + now, + now, + ).Scan( + &stationery.StationeryCode, + &stationery.StationeryName, + &stationery.Cost, + &stationery.Price, + &stationery.Discount, + &stationery.Quantity, + &stationery.Color, + &stationery.Material, + &stationery.Dimensions, + &stationery.Category, + &stationery.Description, + &stationery.ImageURL, + &stationery.Slug, + &stationery.CreatedAt, + &stationery.UpdatedAt, + ) + + if err != nil { + // Check for duplicate key error + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { // unique_violation + return nil, fmt.Errorf("stationery with this code already exists") + } + } + return nil, fmt.Errorf("failed to create stationery: %w", err) + } + + return &stationery, nil +} + +// GetStationeryByCode retrieves a stationery item by its code +func GetStationeryByCode(ctx context.Context, stationeryCode string) (*models.Stationery, error) { + if DB == nil { + return nil, fmt.Errorf("database connection not initialized") + } + + query := ` + SELECT + stationery_code, stationery_name, cost, price, discount, quantity, + color, material, dimensions, category, description, image_url, slug, + created_at, updated_at + FROM stationery + WHERE stationery_code = $1 + ` + + var stationery models.Stationery + err := DB.QueryRow(ctx, query, stationeryCode).Scan( + &stationery.StationeryCode, + &stationery.StationeryName, + &stationery.Cost, + &stationery.Price, + &stationery.Discount, + &stationery.Quantity, + &stationery.Color, + &stationery.Material, + &stationery.Dimensions, + &stationery.Category, + &stationery.Description, + &stationery.ImageURL, + &stationery.Slug, + &stationery.CreatedAt, + &stationery.UpdatedAt, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("stationery not found") + } + return nil, fmt.Errorf("failed to get stationery: %w", err) + } + + return &stationery, nil +} + +// StationerySlugExists checks if a slug already exists in the stationery table +func StationerySlugExists(ctx context.Context, slug string) (bool, error) { + if DB == nil { + return false, fmt.Errorf("database connection not initialized") + } + + query := `SELECT EXISTS(SELECT 1 FROM stationery WHERE slug = $1)` + var exists bool + err := DB.QueryRow(ctx, query, slug).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check slug existence: %w", err) + } + + return exists, nil +} diff --git a/services/stationery_service_test.go b/services/stationery_service_test.go new file mode 100644 index 0000000..8e2176a --- /dev/null +++ b/services/stationery_service_test.go @@ -0,0 +1,84 @@ +package services + +import ( + "context" + "testing" + + "jd-book-uploader/models" +) + +func TestCreateStationery(t *testing.T) { + // This test requires a running database + // Skip if not available + t.Skip("Skipping CreateStationery test - requires running database") + + ctx := context.Background() + + // This test would require: + // 1. Database connection initialized + // 2. Valid test data + // 3. Cleanup after test + + req := &models.StationeryCreateRequest{ + StationeryName: "Test Pen", + Cost: 2.50, + Price: 5.99, + Discount: 0, + Quantity: 200, + Color: "Blue", + Material: "Plastic", + Dimensions: "15cm", + Category: "Writing", + Description: "A test pen", + ImageURL: "https://example.com/image.png", + } + + stationery, err := CreateStationery(ctx, req) + if err != nil { + t.Fatalf("CreateStationery() error = %v", err) + } + + if stationery == nil { + t.Fatal("CreateStationery() returned nil stationery") + } + + if stationery.StationeryCode == "" { + t.Error("CreateStationery() did not generate stationery_code") + } + + if stationery.Slug == "" { + t.Error("CreateStationery() did not generate slug") + } + + if stationery.Slug != "test-pen" { + t.Errorf("CreateStationery() slug = %q, want %q", stationery.Slug, "test-pen") + } +} + +func TestGetStationeryByCode(t *testing.T) { + // This test requires a running database + // Skip if not available + t.Skip("Skipping GetStationeryByCode test - requires running database") + + ctx := context.Background() + + // This test would require: + // 1. Database connection initialized + // 2. A stationery item already in the database + // 3. Valid stationery_code + + stationeryCode := "test-stationery-code" + stationery, err := GetStationeryByCode(ctx, stationeryCode) + if err != nil { + t.Logf("GetStationeryByCode() error = %v (expected if stationery not found)", err) + return + } + + if stationery == nil { + t.Error("GetStationeryByCode() returned nil stationery") + } + + if stationery.StationeryCode != stationeryCode { + t.Errorf("GetStationeryByCode() stationery_code = %q, want %q", stationery.StationeryCode, stationeryCode) + } +} diff --git a/utils/slug.go b/utils/slug.go new file mode 100644 index 0000000..d6a7265 --- /dev/null +++ b/utils/slug.go @@ -0,0 +1,87 @@ +package utils + +import ( + "regexp" + "strings" + "unicode" +) + +// GenerateSlug converts a string to a URL-friendly slug +// Converts to lowercase, replaces spaces with hyphens, removes special characters +func GenerateSlug(text string) string { + if text == "" { + return "" + } + + // Convert to lowercase + slug := strings.ToLower(text) + + // Replace spaces and underscores with hyphens + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") + + // Remove all non-alphanumeric characters except hyphens + reg := regexp.MustCompile(`[^a-z0-9-]`) + slug = reg.ReplaceAllString(slug, "") + + // Remove multiple consecutive hyphens + reg = regexp.MustCompile(`-+`) + slug = reg.ReplaceAllString(slug, "-") + + // Remove leading and trailing hyphens + slug = strings.Trim(slug, "-") + + // If empty after processing, return a default + if slug == "" { + slug = "item" + } + + return slug +} + +// IsValidSlug checks if a string is a valid slug format +func IsValidSlug(slug string) bool { + if slug == "" { + return false + } + + // Check if it contains only lowercase letters, numbers, and hyphens + reg := regexp.MustCompile(`^[a-z0-9-]+$`) + if !reg.MatchString(slug) { + return false + } + + // Check it doesn't start or end with hyphen + if strings.HasPrefix(slug, "-") || strings.HasSuffix(slug, "-") { + return false + } + + // Check it doesn't have consecutive hyphens + if strings.Contains(slug, "--") { + return false + } + + return true +} + +// CleanString removes leading/trailing whitespace and normalizes spaces +func CleanString(s string) string { + // Trim whitespace + s = strings.TrimSpace(s) + + // Replace multiple spaces with single space + reg := regexp.MustCompile(`\s+`) + s = reg.ReplaceAllString(s, " ") + + return s +} + +// ContainsOnlyLetters checks if a string contains only letters (and spaces) +func ContainsOnlyLetters(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsSpace(r) { + return false + } + } + return true +} diff --git a/utils/slug_test.go b/utils/slug_test.go new file mode 100644 index 0000000..35ec396 --- /dev/null +++ b/utils/slug_test.go @@ -0,0 +1,177 @@ +package utils + +import "testing" + +func TestGenerateSlug(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple text", + input: "Hello World", + expected: "hello-world", + }, + { + name: "text with special characters", + input: "Book Name!@#$%", + expected: "book-name", + }, + { + name: "text with numbers", + input: "Book 123", + expected: "book-123", + }, + { + name: "text with multiple spaces", + input: "Book Name Here", + expected: "book-name-here", + }, + { + name: "text with underscores", + input: "Book_Name_Here", + expected: "book-name-here", + }, + { + name: "text with mixed case", + input: "My Awesome Book", + expected: "my-awesome-book", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only special characters", + input: "!@#$%", + expected: "item", + }, + { + name: "leading and trailing spaces", + input: " Book Name ", + expected: "book-name", + }, + { + name: "text with hyphens", + input: "Book-Name-Here", + expected: "book-name-here", + }, + { + name: "text with consecutive special chars", + input: "Book!!!Name", + expected: "bookname", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateSlug(tt.input) + if result != tt.expected { + t.Errorf("GenerateSlug(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsValidSlug(t *testing.T) { + tests := []struct { + name string + slug string + expected bool + }{ + { + name: "valid slug", + slug: "hello-world", + expected: true, + }, + { + name: "valid slug with numbers", + slug: "book-123", + expected: true, + }, + { + name: "invalid - uppercase", + slug: "Hello-World", + expected: false, + }, + { + name: "invalid - special characters", + slug: "hello-world!", + expected: false, + }, + { + name: "invalid - leading hyphen", + slug: "-hello-world", + expected: false, + }, + { + name: "invalid - trailing hyphen", + slug: "hello-world-", + expected: false, + }, + { + name: "invalid - consecutive hyphens", + slug: "hello--world", + expected: false, + }, + { + name: "invalid - empty", + slug: "", + expected: false, + }, + { + name: "valid - single word", + slug: "book", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidSlug(tt.slug) + if result != tt.expected { + t.Errorf("IsValidSlug(%q) = %v, want %v", tt.slug, result, tt.expected) + } + }) + } +} + +func TestCleanString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal string", + input: "Hello World", + expected: "Hello World", + }, + { + name: "with leading/trailing spaces", + input: " Hello World ", + expected: "Hello World", + }, + { + name: "with multiple spaces", + input: "Hello World", + expected: "Hello World", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CleanString(tt.input) + if result != tt.expected { + t.Errorf("CleanString(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/utils/validation.go b/utils/validation.go new file mode 100644 index 0000000..278df2f --- /dev/null +++ b/utils/validation.go @@ -0,0 +1,182 @@ +package utils + +import ( + "fmt" + "strconv" + "strings" +) + +// ValidationError represents a validation error with field name +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// ValidateBookFields validates all book form fields +func ValidateBookFields(bookName, cost, price, discount, quantity, publisherAuthor, category, description string) []*ValidationError { + var errors []*ValidationError + + // Book Name + bookName = strings.TrimSpace(bookName) + if bookName == "" { + errors = append(errors, &ValidationError{Field: "book_name", Message: "is required"}) + } else if len(bookName) > 200 { + errors = append(errors, &ValidationError{Field: "book_name", Message: "exceeds 200 characters"}) + } + + // Cost + if cost == "" { + errors = append(errors, &ValidationError{Field: "cost", Message: "is required"}) + } else { + costVal, err := strconv.ParseFloat(cost, 64) + if err != nil || costVal <= 0 { + errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"}) + } + } + + // Price + if price == "" { + errors = append(errors, &ValidationError{Field: "price", Message: "is required"}) + } else { + priceVal, err := strconv.ParseFloat(price, 64) + if err != nil || priceVal <= 0 { + errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"}) + } + } + + // Discount (optional) + if discount != "" { + discountVal, err := strconv.ParseFloat(discount, 64) + if err != nil || discountVal < 0 { + errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"}) + } + } + + // Quantity + if quantity == "" { + errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"}) + } else { + quantityVal, err := strconv.Atoi(quantity) + if err != nil || quantityVal <= 0 { + errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"}) + } + } + + // Publisher/Author + publisherAuthor = strings.TrimSpace(publisherAuthor) + if publisherAuthor == "" { + errors = append(errors, &ValidationError{Field: "publisher_author", Message: "is required"}) + } else if len(publisherAuthor) > 200 { + errors = append(errors, &ValidationError{Field: "publisher_author", Message: "exceeds 200 characters"}) + } + + // Category + category = strings.TrimSpace(category) + if category == "" { + errors = append(errors, &ValidationError{Field: "category", Message: "is required"}) + } else if len(category) > 100 { + errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"}) + } + + // Description (optional) + if description != "" && len(description) > 1000 { + errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"}) + } + + return errors +} + +// ValidateStationeryFields validates all stationery form fields +func ValidateStationeryFields(stationeryName, cost, price, discount, quantity, category, description string) []*ValidationError { + var errors []*ValidationError + + // Stationery Name + stationeryName = strings.TrimSpace(stationeryName) + if stationeryName == "" { + errors = append(errors, &ValidationError{Field: "stationery_name", Message: "is required"}) + } else if len(stationeryName) > 200 { + errors = append(errors, &ValidationError{Field: "stationery_name", Message: "exceeds 200 characters"}) + } + + // Cost + if cost == "" { + errors = append(errors, &ValidationError{Field: "cost", Message: "is required"}) + } else { + costVal, err := strconv.ParseFloat(cost, 64) + if err != nil || costVal <= 0 { + errors = append(errors, &ValidationError{Field: "cost", Message: "must be a positive number"}) + } + } + + // Price + if price == "" { + errors = append(errors, &ValidationError{Field: "price", Message: "is required"}) + } else { + priceVal, err := strconv.ParseFloat(price, 64) + if err != nil || priceVal <= 0 { + errors = append(errors, &ValidationError{Field: "price", Message: "must be a positive number"}) + } + } + + // Discount (optional) + if discount != "" { + discountVal, err := strconv.ParseFloat(discount, 64) + if err != nil || discountVal < 0 { + errors = append(errors, &ValidationError{Field: "discount", Message: "must be a non-negative number"}) + } + } + + // Quantity + if quantity == "" { + errors = append(errors, &ValidationError{Field: "quantity", Message: "is required"}) + } else { + quantityVal, err := strconv.Atoi(quantity) + if err != nil || quantityVal <= 0 { + errors = append(errors, &ValidationError{Field: "quantity", Message: "must be a positive integer"}) + } + } + + // Category + category = strings.TrimSpace(category) + if category == "" { + errors = append(errors, &ValidationError{Field: "category", Message: "is required"}) + } else if len(category) > 100 { + errors = append(errors, &ValidationError{Field: "category", Message: "exceeds 100 characters"}) + } + + // Description (optional) + if description != "" && len(description) > 1000 { + errors = append(errors, &ValidationError{Field: "description", Message: "exceeds 1000 characters"}) + } + + return errors +} + +// ValidateFileType checks if file type is allowed +func ValidateFileType(contentType string) error { + allowedTypes := map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/jpg": true, + "image/webp": true, + } + + if !allowedTypes[contentType] { + return fmt.Errorf("invalid file type. Only PNG, JPEG, and WEBP are allowed") + } + + return nil +} + +// ValidateFileSize checks if file size is within limit +func ValidateFileSize(size, maxSize int64) error { + if size > maxSize { + return fmt.Errorf("file size exceeds %dMB limit", maxSize/(1024*1024)) + } + + return nil +} diff --git a/utils/validation_test.go b/utils/validation_test.go new file mode 100644 index 0000000..63bf0a6 --- /dev/null +++ b/utils/validation_test.go @@ -0,0 +1,195 @@ +package utils + +import "testing" + +func TestValidateBookFields(t *testing.T) { + tests := []struct { + name string + bookName string + cost string + price string + discount string + quantity string + publisherAuthor string + category string + description string + wantErrors int + }{ + { + name: "valid fields", + bookName: "Test Book", + cost: "10.50", + price: "15.99", + discount: "0", + quantity: "100", + publisherAuthor: "Test Publisher", + category: "Fiction", + description: "A test book", + wantErrors: 0, + }, + { + name: "missing book_name", + bookName: "", + cost: "10.50", + price: "15.99", + quantity: "100", + publisherAuthor: "Test Publisher", + category: "Fiction", + wantErrors: 1, + }, + { + name: "invalid cost", + bookName: "Test Book", + cost: "-10", + price: "15.99", + quantity: "100", + publisherAuthor: "Test Publisher", + category: "Fiction", + wantErrors: 1, + }, + { + name: "missing category", + bookName: "Test Book", + cost: "10.50", + price: "15.99", + quantity: "100", + publisherAuthor: "Test Publisher", + category: "", + wantErrors: 1, + }, + { + name: "description too long", + bookName: "Test Book", + cost: "10.50", + price: "15.99", + quantity: "100", + publisherAuthor: "Test Publisher", + category: "Fiction", + description: string(make([]byte, 1001)), // 1001 characters + wantErrors: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errors := ValidateBookFields( + tt.bookName, + tt.cost, + tt.price, + tt.discount, + tt.quantity, + tt.publisherAuthor, + tt.category, + tt.description, + ) + + if len(errors) != tt.wantErrors { + t.Errorf("ValidateBookFields() returned %d errors, want %d", len(errors), tt.wantErrors) + } + }) + } +} + +func TestValidateStationeryFields(t *testing.T) { + tests := []struct { + name string + stationeryName string + cost string + price string + quantity string + category string + wantErrors int + }{ + { + name: "valid fields", + stationeryName: "Test Pen", + cost: "2.50", + price: "5.99", + quantity: "200", + category: "Writing", + wantErrors: 0, + }, + { + name: "missing stationery_name", + stationeryName: "", + cost: "2.50", + price: "5.99", + quantity: "200", + category: "Writing", + wantErrors: 1, + }, + { + name: "invalid quantity", + stationeryName: "Test Pen", + cost: "2.50", + price: "5.99", + quantity: "-10", + category: "Writing", + wantErrors: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errors := ValidateStationeryFields( + tt.stationeryName, + tt.cost, + tt.price, + "", + tt.quantity, + tt.category, + "", + ) + + if len(errors) != tt.wantErrors { + t.Errorf("ValidateStationeryFields() returned %d errors, want %d", len(errors), tt.wantErrors) + } + }) + } +} + +func TestValidateFileType(t *testing.T) { + tests := []struct { + name string + contentType string + wantErr bool + }{ + {"valid PNG", "image/png", false}, + {"valid JPEG", "image/jpeg", false}, + {"valid JPG", "image/jpg", false}, + {"valid WEBP", "image/webp", false}, + {"invalid type", "image/gif", true}, + {"invalid type", "text/plain", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFileType(tt.contentType) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateFileType() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateFileSize(t *testing.T) { + tests := []struct { + name string + size int64 + maxSize int64 + wantErr bool + }{ + {"valid size", 5 * 1024 * 1024, 10 * 1024 * 1024, false}, + {"exact max size", 10 * 1024 * 1024, 10 * 1024 * 1024, false}, + {"too large", 11 * 1024 * 1024, 10 * 1024 * 1024, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFileSize(tt.size, tt.maxSize) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateFileSize() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}