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`.
This commit is contained in:
ianshaloom
2025-11-21 08:50:27 +03:00
commit ebeae34e01
39 changed files with 4044 additions and 0 deletions

151
services/book_service.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

85
services/database.go Normal file
View File

@@ -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)
}

57
services/database_test.go Normal file
View File

@@ -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")
}
}

141
services/firebase.go Normal file
View File

@@ -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
}

108
services/firebase_test.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}