diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b73c9b --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +APP_NAME=library +APP_VERSION=0.1.0 +APP_ENV=development + +# Application Port +# For local development (make run-api): the port your app listens on +# For Docker (make docker-up): container always listens on 8080 (hardcoded in docker-compose.yml) +PORT=8080 + +# Docker Host Port (optional, only used by docker-compose) +# Maps host port to container port 8080 +# HOST_PORT=8080 + +DATABASE_URL=postgres://library:secret@localhost:5432/library?sslmode=disable +POSTGRES_USER=library +POSTGRES_PASSWORD=secret +POSTGRES_DB=library + +# ⚠️ For local development (make run-api): use localhost:6380 +# ⚠️ For Docker (make docker-up): IGNORED - hardcoded to redis://redis:6379 in docker-compose.yml +REDIS_URL=redis://localhost:6380 + +JWT_SECRET=secret +ACCESS_TOKEN_EXPIRY=30 +REFRESH_TOKEN_EXPIRY=720 + +GOOGLE_CLIENT_ID=xxx +GOOGLE_CLIENT_SECRET=xxx +GOOGLE_REDIRECT_URL=http://localhost:8080/api/v1/portal/auth/google/callback + +OTP_EXPIRY=1 + +# Elasticsearch Configuration +# ⚠️ For local development (make run-api): use localhost:9200 +# ⚠️ For Docker (make docker-up): IGNORED - hardcoded to http://elasticsearch:9200 in docker-compose.yml +ELASTICSEARCH_URL=http://localhost:9200 + +REQUEST_TIMEOUT=30s +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c502ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=5m + + test-unit: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Download modules + run: go mod download + + - name: Run unit tests + run: go test ./internal/... -v -race -count=1 -coverprofile=coverage.out + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + test-integration: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Download modules + run: go mod download + + - name: Run integration tests + run: go test ./tests/integration/... -v -race -count=1 -timeout 120s + env: + TESTCONTAINERS_RYUK_DISABLED: true + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test-unit, test-integration] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Build all binaries + run: make build + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: binaries + path: bin/ + + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t librecore:${{ github.sha }} . + + - name: Smoke test image + run: | + docker run --rm -d \ + -e APP_ENV=production \ + -e DATABASE_URL=postgres://x:x@localhost/x \ + -e REDIS_URL=redis://localhost:6379 \ + -e JWT_SECRET=ci-test-secret \ + -p 8080:8080 \ + --name librecore_smoke \ + librecore:${{ github.sha }} || true + sleep 3 + docker stop librecore_smoke || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index aaadf73..669473a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +bin/ # Test binary, built with `go test -c` *.test @@ -28,5 +29,5 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..01e4c24 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,50 @@ +version: "2" +run: + timeout: 5m + go: '1.26' + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - revive + - exhaustive + - noctx + - bodyclose + settings: + revive: + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true + - name: redefines-builtin-id + severity: warning + - name: unexported-return + severity: warning + - name: blank-imports + severity: warning + exhaustive: + default-signifies-exhaustive: true + exclusions: + rules: + - path: _test\.go + linters: [errcheck, noctx] + - path: tests/ + linters: [errcheck, noctx, revive] + - path: docs/ + linters: [all] + - path: pkg/i18n/fa\.go + linters: [staticcheck] + +formatters: + enable: + - gofmt + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a682878 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# Contributing to LibreCore + +Thank you for considering contributing. This document covers everything +you need to get started. + +--- + +## Development Setup + +### Prerequisites +- Go 1.23+ +- Docker + Docker Compose +- `golangci-lint` — `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` +- `swag` — `go install github.com/swaggo/swag/cmd/swag@latest` + +### First-time setup +```bash +git clone https://github.com/mohammad-farrokhnia/library.git +cd library +cp .env.example .env +make docker-up +make migrate +make seed EMAIL=admin@library.com PASSWORD=secret MOBILE=09120000000 NATIONAL_CODE=1234567890 +make run-api +``` + +--- + +## Git Flow + +We follow trunk-based development with short-lived feature branches. + +``` +main ← production, protected, requires PR + review +develop ← integration branch, PRs target this +feature/* ← one branch per feature +hotfix/* ← emergency production fixes +release/* ← release stabilization +``` + +--- + +## Branch Naming + +| Type | Pattern | Example | +|---|---|---| +| Feature | `feature/short-description` | `feature/book-recommendations` | +| Bug fix | `fix/what-was-broken` | `fix/borrow-transaction-race` | +| Hotfix | `hotfix/critical-issue` | `hotfix/jwt-validation` | +| Chore | `chore/what-changed` | `chore/update-go-1.24` | + +--- + +## Commit Convention + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): short description + +feat(books): add copy management endpoints +fix(auth): prevent session reuse after logout +docs(readme): update installation steps +test(borrow): add limit enforcement integration test +chore(deps): update golang-jwt to v5.2.1 +refactor(cache): extract GetOrSet into generic helper +``` + +Types: `feat` `fix` `docs` `test` `chore` `refactor` `perf` `ci` + +--- + +## Pull Request Process + +1. Fork the repo and create your branch from `develop` +2. Write tests for any new behaviour +3. Ensure `make lint` and `make test` pass +4. Update Swagger docs if you changed any handler: `make swagger` +5. Open a PR targeting `develop` with a clear description + +--- + +## Code Standards + +- All repository methods must accept `context.Context` as the first parameter +- All errors must be `AppError` from `pkg/errors` — never raw `errors.New` +- Business rules belong in `service/`, never in `handler/` or `repository/` +- New i18n message codes must be added to all locale files (`en.go`, `fa.go`) +- Every new endpoint needs a Swagger annotation + +--- + +## Running Tests + +```bash +make test-unit # fast, no Docker +make test-integration # requires Docker +make test-coverage # generates coverage.html +``` + +--- + +## Project Structure + +See the [architecture document](docs/architecture.md) for the full layered +architecture diagram and design decisions. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbd021a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ + -ldflags "-s -w -X main.Version=${VERSION}" \ + -o main ./cmd/api + +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +COPY --from=builder /app/main . + +COPY --from=builder /app/migrations ./migrations + +EXPOSE 8080 + +CMD ["./main"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a9daf5e --- /dev/null +++ b/Makefile @@ -0,0 +1,135 @@ +APP_NAME := library +BUILD_DIR := bin +GO_FILES := $(shell find . -type f -name '*.go' -not -path './vendor/*' -not -path './docs/*') + +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION)" +GO_VERSION=1.26 +.PHONY: help run-api build build-all test test-unit test-integration test-coverage \ + lint fmt vet tidy docker-up docker-down docker-logs docker-psql \ + migrate migrate-down migrate-version migrate-force seed update-swagger clean + +docker-up: + VERSION=$(VERSION) docker compose up -d --build + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f api + +docker-psql: + docker compose up -d postgres + +docker-redis: + docker compose up -d redis + +docker-elasticsearch: + docker compose up -d elasticsearch + +docker-deps: + docker compose up -d postgres elasticsearch redis + + +migrate: + go run ./cmd/migrate -cmd=up + +migrate-down: + go run ./cmd/migrate -cmd=down -steps=1 + +migrate-version: + go run ./cmd/migrate -cmd=version + +migrate-force: + @echo "Forcing migration to version $(VERSION)" + @if [ -z "$(VERSION)" ]; then echo "Error: VERSION is required. Usage: make migrate-force VERSION=4"; exit 1; fi + go run ./cmd/migrate -cmd=force -version=$(VERSION) + +seed: + go run ./cmd/seed --email=$(EMAIL) --password=$(PASSWORD) --mobile=$(MOBILE) --national-code=$(NATIONAL_CODE) + + +update-swagger: + ~/go/bin/swag init -g cmd/api/main.go -o docs --parseDependency +run-api: + go run $(LDFLAGS) ./cmd/api +build-api: + CGO_ENABLED=0 go build $(LDFLAGS) -o $(BUILD_DIR)/$(APP_NAME) ./cmd/api + +build-migrate: + CGO_ENABLED=0 go build -o $(BUILD_DIR)/migrate ./cmd/migrate + +build-seed: + CGO_ENABLED=0 go build -o $(BUILD_DIR)/seed ./cmd/seed + +build: build-api build-seed build-migrate + +test-unit: + go test ./internal/... -v -race -count=1 + +test-integration: + go test ./tests/integration/... -v -race -count=1 -timeout 120s + +test: test-unit test-integration + +test-coverage: + go test ./... -coverprofile=coverage.out -covermode=atomic + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +lint: + golangci-lint run ./... --timeout=5m + +fmt: + gofmt -w $(GO_FILES) + goimports -w $(GO_FILES) + +vet: + go vet ./... + +tidy: + go mod tidy + go mod verify + +clean: + rm -rf $(BUILD_DIR) coverage.out coverage.html + +help: + @echo "$(APP_NAME) — available commands:" + @echo "" + @echo "Development:" + @echo " run-api Run the API server locally" + @echo "" + @echo "Build:" + @echo " build Build all binaries (api, seed, migrate)" + @echo " build-api Build API binary with version ($(VERSION))" + @echo " build-seed Build seed utility" + @echo " build-migrate Build migration utility" + @echo "" + @echo "Testing:" + @echo " test Run all tests (unit + integration)" + @echo " test-unit Run unit tests only" + @echo " test-integration Run integration tests (requires Docker)" + @echo " test-coverage Run tests with coverage report" + @echo "" + @echo "Code Quality:" + @echo " lint Run golangci-lint" + @echo " fmt Format Go code" + @echo " vet Run go vet" + @echo " tidy Tidy go modules" + @echo "" + @echo "Database:" + @echo " docker-deps Start postgres, redis, elasticsearch" + @echo " docker-up Start all services with docker compose (builds with version $(VERSION))" + @echo " docker-down Stop all services" + @echo " migrate Run migrations up" + @echo " migrate-down Run migrations down (1 step)" + @echo " migrate-version Show migration version" + @echo " migrate-force Force migration to version (usage: make migrate-force VERSION=4)" + @echo " seed Seed super admin user" + @echo "" + @echo "Other:" + @echo " update-swagger Regenerate Swagger docs" + @echo " clean Remove build artifacts" + @echo " help Show this help message" + @echo "" \ No newline at end of file diff --git a/README.md b/README.md index f70006a..48bb1d3 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ > A production-grade Library Management REST API built with Go +![CI](https://github.com/mohammad-farrokhnia/library/actions/workflows/ci.yml/badge.svg) ![Go](https://img.shields.io/badge/Go-1.23-00ADD8?style=flat&logo=go) ![License](https://img.shields.io/badge/License-MIT-green?style=flat) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791?style=flat&logo=postgresql) ![Elasticsearch](https://img.shields.io/badge/Elasticsearch-8.x-005571?style=flat&logo=elasticsearch) - +![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=flat&logo=redis) --- ## What Is This? @@ -92,6 +93,7 @@ git clone https://github.com/YOUR_USERNAME/librecore.git cd librecore cp .env.example .env +# Edit .env if needed (see Configuration section below) docker compose up -d @@ -103,6 +105,30 @@ make run The API will be available at `http://localhost:8080`. Swagger UI at `http://localhost:8080/docs`. +### Configuration + +The `.env` file contains different connection strings depending on how you run the application: + +**For local development (`make run-api`):** +- Use `localhost` with host-mapped ports +- Redis: `redis://localhost:6380` (Docker maps 6380→6379) +- Elasticsearch: `http://localhost:9200` + +**For Docker (`make docker-up`):** +- Environment variables are automatically overridden in `docker-compose.yml` +- Redis: `redis://redis:6379` (internal Docker network) +- Elasticsearch: `http://elasticsearch:9200` (internal Docker network) + +**For production:** +- Point to your managed services (AWS ElastiCache, Elastic Cloud, etc.) +- Set environment variables to override defaults: + ```bash + export REDIS_URL=redis://your-redis-cluster.amazonaws.com:6379 + export ELASTICSEARCH_URL=https://your-es-cluster.elastic-cloud.com:9200 + docker compose up -d + ``` +- Or update your production `.env` file directly with external service URLs + ### Makefile Commands ```bash @@ -114,6 +140,34 @@ make docker # build and start all containers make lint # run golangci-lint ``` +### Building with Version + +The API binary embeds version information at build time using git tags or commit hash: + +```bash +# Build locally with version (uses git tag or commit hash) +make build-api + +# Build and run with Docker (automatically injects version) +make docker-up + +# The version is injected at compile time and logged on startup: +# [library] listening on :8080 env=development version=e5d4239 +``` + +For manual builds: + +```bash +# Local build with version from git +go build -ldflags "-X main.Version=$(git describe --tags --always --dirty)" -o bin/library ./cmd/api + +# Docker build with specific version +docker build --build-arg VERSION=1.2.3 -t library-api . + +# Docker Compose with custom version +VERSION=1.2.3 docker compose up -d --build +``` + --- ## API Overview @@ -178,7 +232,56 @@ Request → Middleware (auth, logger) → Handler → Service → Repository → Each layer has one responsibility and only communicates with the layer directly below it. Business rules live exclusively in `service/`. Handlers never touch the database. +--- +## Security Considerations & Known Limitations + +⚠️ **This project is for educational/portfolio purposes. Before deploying to production, address the following:** + +### 🔴 Critical Security Issues + +1. **Google OAuth State Parameter (CSRF Vulnerability)** + - **Issue**: The `GoogleCallback` handler does not verify the OAuth `state` parameter + - **Risk**: Attackers can forge OAuth callbacks and potentially hijack authentication flows + - **Fix Required**: Store state in Redis with 5-minute TTL before redirecting to Google, then verify it in the callback: + ```go + // Before redirect + state := generateRandomState() + rdb.Set(ctx, "oauth:state:"+state, userID, 5*time.Minute) + + // In callback + storedUserID := rdb.Get(ctx, "oauth:state:"+state) + if storedUserID == "" { + return errors.New("invalid state") + } + ``` + +2. **Hardcoded OTP for Password Reset** + - **Issue**: Email OTP verification is not implemented; OTPs are logged but not sent + - **Risk**: Password reset flow is non-functional in production + - **Fix Required**: Integrate a real email service (SendGrid, AWS SES, etc.) in `pkg/mailer` + +### 🟡 Data Integrity Limitations + +3. **No Soft Deletes** + - **Issue**: Deleting customers/employees permanently removes records and orphans borrow history + - **Impact**: Historical data is lost; reports become inaccurate + - **Recommendation**: Add `deleted_at TIMESTAMPTZ` columns and filter with `WHERE deleted_at IS NULL` + +4. **Elasticsearch Sync Has No Recovery** + - **Issue**: Book indexing is fire-and-forget; if the API crashes between DB write and ES index, search results become stale + - **Impact**: Search index can drift from database state + - **Proper Solution**: Implement an outbox pattern with `es_sync_queue` table and background worker + - **Current Workaround**: Manually reindex via admin script if drift is detected + +### ✅ Already Implemented Correctly + +- **Unique Constraint Checking**: Uses pgx error codes (`23505`) instead of fragile string matching +- **JWT Security**: RS256 with proper key rotation support +- **SQL Injection Protection**: All queries use parameterized statements +- **Password Hashing**: bcrypt with cost factor 12 + +--- ## License diff --git a/cmd/api/backoffice_router.go b/cmd/api/backoffice_router.go new file mode 100644 index 0000000..b0bbc15 --- /dev/null +++ b/cmd/api/backoffice_router.go @@ -0,0 +1,111 @@ +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/middleware" +) + +func registerBackofficeRoutes(api *gin.RouterGroup, deps *Dependencies) { + apiV1BackOffice := api.Group("/backoffice") + { + registerAuthRoutes(apiV1BackOffice, deps) + apiV1BackOffice.Use(middleware.RequireAuth(deps.AuthService, deps.SessionStore, domain.SubjectTypeEmployee)) + apiV1BackOffice.Use(middleware.RequireRole(domain.RoleManager, domain.RoleSuperAdmin)) + registerBookRoutes(apiV1BackOffice, deps) + registerCustomerRoutes(apiV1BackOffice, deps) + registerEmployeeRoutes(apiV1BackOffice, deps) + registerBorrowRoutes(apiV1BackOffice, deps) + registerBackofficeSearchRoutes(apiV1BackOffice, deps) + registerReportRoutes(apiV1BackOffice, deps) + } +} + +func registerAuthRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.EmployeeAuthHandler + auth := api.Group("/auth") + { + auth.POST("/login", handler.LoginViaPassword) + auth.POST("/refresh", handler.Refresh) + auth.POST("/forgot-password", handler.ForgotPassword) + auth.POST("/reset-password", handler.ResetPassword) + auth.POST("/logout", + middleware.RequireAuth(deps.AuthService, deps.SessionStore, domain.SubjectTypeEmployee), + handler.Logout, + ) + } +} + +func registerBookRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.BookHandler + books := api.Group("/books") + { + books.GET("", handler.List) + books.GET("/:id", handler.GetByID) + books.POST("", handler.Create) + books.PUT("/:id", handler.Update) + books.DELETE("/:id", handler.Delete) + books.POST("/:id/copies/add", handler.AddCopies) + books.POST("/:id/copies/remove", handler.RemoveCopies) + books.GET("/:id/recommendations", deps.RecommendationHandler.ItemBasedBackoffice) + } +} + +func registerCustomerRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.CustomerHandler + customers := api.Group("/customers") + { + customers.GET("", handler.List) + customers.GET("/:id", handler.GetByID) + customers.POST("", handler.Create) + customers.PUT("/:id", handler.Update) + customers.DELETE("/:id", handler.Delete) + } +} + +func registerEmployeeRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.EmployeeHandler + employees := api.Group("/employees") + { + employees.GET("", handler.List) + employees.GET("/:id", handler.GetByID) + employees.POST("", handler.Create) + employees.PUT("/:id", handler.Update) + employees.DELETE("/:id", handler.Delete) + } +} + +func registerBorrowRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.BorrowHandler + borrows := api.Group("/borrows") + { + borrows.GET("", + handler.ListAll, + ) + borrows.POST("", handler.Borrow) + borrows.PUT("/:id/return", handler.Return) + borrows.GET("/customers/:id", handler.ListByCustomer) + } +} + +func registerBackofficeSearchRoutes(api *gin.RouterGroup, deps *Dependencies) { + search := api.Group("/search") + { + search.GET("/books", deps.SearchHandler.BackofficeSearch) + search.GET("/books/suggest", deps.SearchHandler.Suggest) + } +} + +func registerReportRoutes(api *gin.RouterGroup, deps *Dependencies) { + h := deps.ReportHandler + reports := api.Group("/reports") + { + reports.GET("/borrows/trends", h.BorrowTrends) + reports.GET("/borrows/overdue", h.Overdue) + reports.GET("/books/top", h.TopBooks) + reports.GET("/books/low-availability", h.LowAvailability) + reports.GET("/customers/top", h.TopCustomers) + reports.GET("/genres/popularity", h.GenrePopularity) + reports.GET("/summary/monthly", h.MonthlySummary) + } +} diff --git a/cmd/api/deps.go b/cmd/api/deps.go new file mode 100644 index 0000000..5e6d4ab --- /dev/null +++ b/cmd/api/deps.go @@ -0,0 +1,95 @@ +package main + +import ( + "github.com/elastic/go-elasticsearch/v8" + "github.com/jmoiron/sqlx" + "github.com/mohammad-farrokhnia/library/configs" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/internal/search" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/cache" + "github.com/mohammad-farrokhnia/library/pkg/mailer" + "github.com/mohammad-farrokhnia/library/pkg/otp" + "github.com/mohammad-farrokhnia/library/pkg/session" + "github.com/redis/go-redis/v9" +) + +type Dependencies struct { + BookHandler *handler.BookHandler + CustomerHandler *handler.CustomerHandler + EmployeeHandler *handler.EmployeeHandler + EmployeeAuthHandler *handler.EmployeeAuthHandler + BorrowHandler *handler.BorrowHandler + CustomerAuthHandler *handler.CustomerAuthHandler + SearchHandler *handler.SearchHandler + PortalCustomerHandler *handler.PortalCustomerHandler + PortalBookHandler *handler.PortalBookHandler + PortalBorrowHandler *handler.PortalBorrowHandler + RecommendationHandler *handler.RecommendationHandler + ReportHandler *handler.ReportHandler + HealthHandler *handler.HealthHandler + + SessionStore *session.Store + + AuthService *service.AuthService +} + +func initializeDependencies(db *sqlx.DB, rdb *redis.Client, esClient *elasticsearch.Client, cfg *configs.Config) *Dependencies { + bookRepo := repository.NewBookRepository(db) + customerRepo := repository.NewCustomerRepository(db) + employeeRepo := repository.NewEmployeeRepository(db) + borrowRepo := repository.NewBorrowRepository(db) + tokenRepo := repository.NewTokenRepository(db) + + sessionStore := session.NewStore(rdb) + otpStore := otp.NewStore(rdb, cfg.OTPExpiry) + mockMailer := mailer.NewMock() + indexer := search.NewIndexer(esClient) + querier := search.NewQuerier(esClient) + authSvc := service.NewAuthService( + employeeRepo, + customerRepo, + tokenRepo, + sessionStore, + otpStore, + mockMailer, + cfg.JWTSecret, + cfg.AccessTokenExpiry, + cfg.RefreshTokenExpiry, + cfg.GoogleClientID, + cfg.GoogleClientSecret, + cfg.GoogleRedirectURL, + ) + + bookSvc := service.NewBookService(bookRepo, indexer) + customerSvc := service.NewCustomerService(customerRepo) + employeeSvc := service.NewEmployeeService(employeeRepo, authSvc) + borrowSvc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + + cacheStore := cache.New(rdb) + + recommendationRepo := repository.NewRecommendationRepository(db) + recommendationSvc := service.NewRecommendationService(recommendationRepo, cacheStore) + + reportRepo := repository.NewReportRepository(db) + reportSvc := service.NewReportService(reportRepo, cacheStore) + + return &Dependencies{ + BookHandler: handler.NewBookHandler(bookSvc), + CustomerHandler: handler.NewCustomerHandler(customerSvc), + EmployeeHandler: handler.NewEmployeeHandler(employeeSvc), + BorrowHandler: handler.NewBorrowHandler(borrowSvc), + EmployeeAuthHandler: handler.NewEmployeeAuthHandler(authSvc), + CustomerAuthHandler: handler.NewCustomerAuthHandler(authSvc, customerSvc), + AuthService: authSvc, + SessionStore: sessionStore, + SearchHandler: handler.NewSearchHandler(querier), + PortalCustomerHandler: handler.NewPortalCustomerHandler(customerSvc), + PortalBookHandler: handler.NewPortalBookHandler(bookSvc, querier), + PortalBorrowHandler: handler.NewPortalBorrowHandler(borrowSvc), + RecommendationHandler: handler.NewRecommendationHandler(recommendationSvc), + ReportHandler: handler.NewReportHandler(reportSvc), + HealthHandler: handler.NewHealthHandler(db, rdb, esClient, cfg.Version, cfg.AppName), + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..c243e0d --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/elastic/go-elasticsearch/v8" + "github.com/jmoiron/sqlx" + "github.com/mohammad-farrokhnia/library/configs" + _ "github.com/mohammad-farrokhnia/library/docs" + "github.com/mohammad-farrokhnia/library/internal/middleware" + "github.com/mohammad-farrokhnia/library/internal/search" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/redis/go-redis/v9" +) + +var Version = "dev" + +// @title Library API +// @version 1.0 +// @description A comprehensive library management system API with full CRUD operations for books, authors, and members. This API provides a robust foundation for managing library resources with support for internationalization, detailed error handling, and comprehensive logging. +// @contact.name Mohammad Farrokhnia +// @contact.url https://github.com/mohammad-farrokhnia +// @contact.email mohammad.farokhnia.a@gmail.com +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT +// @host localhost:8080 +// @BasePath /api/v1 +// @securityDefinitions.apikey Bearer +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. +func main() { + cfg := loadConfig() + initViaConfig(cfg) + db, err := configs.NewDB(cfg) + if err != nil { + log.Fatalf("database: %v", err) + } + defer closeDb(db) + rdb, err := configs.NewRedis(cfg) + if err != nil { + log.Fatalf("redis: %v", err) + } + defer closeDb(db) + esClient, err := search.NewClient(cfg.ElasticsearchURL) + if err != nil { + log.Fatalf("elasticsearch: %v", err) + } + if err := search.NewIndexer(esClient).EnsureIndex(context.Background()); err != nil { + log.Fatalf("ensure es index: %v", err) + } + srv := createServer(cfg, db, rdb, esClient) + go func() { + log.Printf("[%s] listening on :%s env=%s version=%s", cfg.AppName, cfg.Port, cfg.AppEnv, Version) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutdown signal received — draining connections...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("forced shutdown: %v", err) + } + + log.Println("server stopped cleanly") +} + +func closeDb(db *sqlx.DB) { + if err := db.Close(); err != nil { + log.Printf("db close: %v", err) + } +} +func createServer(cfg *configs.Config, db *sqlx.DB, rdb *redis.Client, esClient *elasticsearch.Client) *http.Server { + return &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.Port), + Handler: setupRouter(cfg.AppEnv, db, cfg, rdb, esClient), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func loadConfig() *configs.Config { + cfg, err := configs.Load() + if err != nil { + log.Fatalf("config error: %v", err) + } + log.Default().Println("server starting ...") + log.Default().Println(cfg) + return cfg +} + +func initViaConfig(cfg *configs.Config) { + response.Init(cfg.AppName, cfg.Version) + middleware.InitLogger(cfg.AppName) + +} diff --git a/cmd/api/portal_router.go b/cmd/api/portal_router.go new file mode 100644 index 0000000..0660871 --- /dev/null +++ b/cmd/api/portal_router.go @@ -0,0 +1,56 @@ +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/middleware" +) + +func registerPortalRoutes(apiV1 *gin.RouterGroup, deps *Dependencies) { + apiV1Portal := apiV1.Group("/portal") + registerPortalAuthRoutes(apiV1Portal, deps) + + authenticated := apiV1Portal.Group("") + authenticated.Use(middleware.RequireAuth(deps.AuthService, deps.SessionStore, domain.SubjectTypeCustomer)) + registerPortalBookRoutes(authenticated, deps) + registerPortalMeRoutes(authenticated, deps) +} + +func registerPortalAuthRoutes(api *gin.RouterGroup, deps *Dependencies) { + handler := deps.CustomerAuthHandler + auth := api.Group("/auth") + { + auth.POST("/signup", handler.Signup) + auth.POST("/login", handler.Login) + auth.POST("/refresh", handler.Refresh) + auth.POST("/forgot-password", handler.ForgotPassword) + auth.POST("/reset-password", handler.ResetPassword) + auth.GET("/google", handler.GoogleRedirect) + auth.GET("/google/callback", handler.GoogleCallback) + auth.POST("/logout", + middleware.RequireAuth(deps.AuthService, deps.SessionStore, domain.SubjectTypeCustomer), + handler.Logout, + ) + } +} + +func registerPortalBookRoutes(apiV1Portal *gin.RouterGroup, deps *Dependencies) { + books := apiV1Portal.Group("/books") + { + books.GET("", deps.PortalBookHandler.List) + books.GET("/search", deps.PortalBookHandler.Search) + books.GET("/suggest", deps.PortalBookHandler.Suggest) + books.GET("/:id", deps.PortalBookHandler.GetByID) + books.GET("/:id/recommendations", deps.RecommendationHandler.ItemBasedPortal) + } +} + +func registerPortalMeRoutes(apiV1Portal *gin.RouterGroup, deps *Dependencies) { + me := apiV1Portal.Group("/me") + { + me.GET("", deps.PortalCustomerHandler.GetMe) + me.PUT("", deps.PortalCustomerHandler.UpdateMe) + me.GET("/borrows", deps.PortalBorrowHandler.GetMyBorrows) + me.GET("/recommendations", deps.RecommendationHandler.ProfileBased) + } +} diff --git a/cmd/api/router.go b/cmd/api/router.go new file mode 100644 index 0000000..729130b --- /dev/null +++ b/cmd/api/router.go @@ -0,0 +1,64 @@ +package main + +import ( + "github.com/elastic/go-elasticsearch/v8" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/mohammad-farrokhnia/library/configs" + "github.com/redis/go-redis/v9" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + "github.com/mohammad-farrokhnia/library/internal/middleware" +) + +func setupRouter(appEnv string, db *sqlx.DB, cfg *configs.Config, rdb *redis.Client, esClient *elasticsearch.Client) *gin.Engine { + ginEngine := configureGin(appEnv, cfg, rdb) + deps := initializeDependencies(db, rdb, esClient, cfg) + registerRoutes(ginEngine, deps) + return ginEngine +} + +func configureGin(appEnv string, cfg *configs.Config, rdb *redis.Client) *gin.Engine { + switch appEnv { + case "production": + gin.SetMode(gin.ReleaseMode) + case "development": + gin.SetMode(gin.DebugMode) + default: + gin.SetMode(gin.TestMode) + } + + ginEngine := gin.New() + + ginEngine.Use(middleware.Timeout(cfg.RequestTimeout)) + ginEngine.Use(middleware.SecurityHeaders()) + ginEngine.Use(middleware.CORS(cfg.AllowedOrigins)) + ginEngine.Use(middleware.Logger()) + ginEngine.Use(middleware.Recovery()) + ginEngine.Use(middleware.RateLimit(rdb, cfg.RateLimitRequests, cfg.RateLimitWindow)) + + ginEngine.Use(middleware.Logger()) + ginEngine.Use(middleware.Recovery()) + + return ginEngine +} + +func registerRoutes(ginEngine *gin.Engine, deps *Dependencies) { + apiV1 := ginEngine.Group("/api/v1") + { + apiV1.GET("/health", deps.HealthHandler.Health) + apiV1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } + registerBackofficeRoutes(apiV1, deps) + registerPortalRoutes(apiV1, deps) + registerPublicRoutes(apiV1, deps) +} + +func registerPublicRoutes(apiV1 *gin.RouterGroup, deps *Dependencies) { + search := apiV1.Group("/search") + { + search.GET("/books", deps.SearchHandler.PublicSearch) + search.GET("/books/suggest", deps.SearchHandler.Suggest) + } +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..aadbe54 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "flag" + "log" + "os" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/joho/godotenv" +) + +func main() { + cmd := flag.String("cmd", "up", "Migration command: up | down | drop | version | force") + steps := flag.Int("steps", 0, "Number of steps for up/down (0 = all)") + forceVersion := flag.Int("version", 0, "Version to force (used with force command)") + flag.Parse() + + _ = godotenv.Load() + + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + log.Fatal("DATABASE_URL environment variable is not set") + } + + m, err := migrate.New("file://migrations", "pgx5://"+dbURL[len("postgres://"):]) + if err != nil { + log.Fatalf("failed to init migrate: %v", err) + } + defer func() { + srcErr, dbErr := m.Close() + if srcErr != nil { + log.Printf("migrate source close error: %v", srcErr) + } + if dbErr != nil { + log.Printf("migrate db close error: %v", dbErr) + } + }() + + switch *cmd { + case "up": + if *steps > 0 { + err = m.Steps(*steps) + } else { + err = m.Up() + } + case "down": + if *steps > 0 { + err = m.Steps(-(*steps)) + } else { + err = m.Down() + } + case "drop": + log.Println("WARNING: dropping all tables...") + err = m.Drop() + case "version": + version, dirty, verErr := m.Version() + if verErr != nil { + log.Fatalf("version error: %v", verErr) + } + log.Printf("current version: %d | dirty: %v", version, dirty) + return + case "force": + if *forceVersion == 0 { + log.Fatal("must specify -version for force command") + } + if err := m.Force(*forceVersion); err != nil { + log.Fatalf("force failed: %v", err) + } + log.Printf("forced version to %d", *forceVersion) + return + default: + log.Fatalf("unknown command: %s. Use: up | down | drop | version", *cmd) + } + + if err != nil && err != migrate.ErrNoChange { + log.Fatalf("migration [%s] failed: %v", *cmd, err) + } + + if err == migrate.ErrNoChange { + log.Println("no migrations to apply — already up to date") + return + } + + log.Printf("migration [%s] completed successfully", *cmd) +} diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..b1ad797 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "flag" + "log" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" + + "github.com/mohammad-farrokhnia/library/configs" + "github.com/mohammad-farrokhnia/library/pkg/util" +) + +func main() { + email := flag.String("email", "super_admin@library.com", "super_admin email") + password := flag.String("password", "", "super_admin password (required)") + mobile := flag.String("mobile", "09123456789", "super_admin mobile") + nationalCode := flag.String("national-code", "1234567890", "super_admin national code") + flag.Parse() + + if *password == "" { + log.Fatal("--password is required") + } + + _ = godotenv.Load() + + cfg, err := configs.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := configs.NewDB(cfg) + if err != nil { + log.Fatalf("db: %v", err) + } + defer closeDb(db) + + if err := seedSuperAdmin(db, *email, *password, *mobile, *nationalCode); err != nil { + log.Fatalf("seed: %v", err) + } +} + +func closeDb(db *sqlx.DB) { + if err := db.Close(); err != nil { + log.Printf("db close: %v", err) + } +} + +func seedSuperAdmin(db *sqlx.DB, email, password, mobile, nationalCode string) error { + ctx := context.Background() + + var exists bool + err := db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM employees WHERE role = 'super_admin')`, + ).Scan(&exists) + if err != nil { + return err + } + if exists { + log.Printf("super_admin already exists — skipping") + return nil + } + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + emailShowcase := email + email = util.StripEmailLocalPartSymbols(email) + + _, err = db.ExecContext(ctx, ` + INSERT INTO employees (first_name, last_name, email, mobile, national_code, password, role, email_showcase) + VALUES ('Super', 'Admin', $1, $2, $3, $4, 'super_admin', $5)`, + email, mobile, nationalCode, string(hashed), emailShowcase) + if err != nil { + return err + } + + log.Printf("super_admin created: %s", email) + return nil +} diff --git a/configs/config.go b/configs/config.go new file mode 100644 index 0000000..4d8be53 --- /dev/null +++ b/configs/config.go @@ -0,0 +1,164 @@ +package configs + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + AppName string + AppEnv string + Port string + Version string + + DBUrl string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetime time.Duration + DBConnMaxIdleTime time.Duration + + JWTSecret string + JWTExpiryHours time.Duration + + AccessTokenExpiry time.Duration + RefreshTokenExpiry time.Duration + + RedisURL string + + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + + OTPExpiry time.Duration + + ElasticsearchURL string + + RequestTimeout time.Duration + AllowedOrigins []string + RateLimitRequests int + RateLimitWindow time.Duration +} + +func (c *Config) IsDevelopment() bool { return c.AppEnv == "development" } +func (c *Config) IsProduction() bool { return c.AppEnv == "production" } +func (c *Config) IsTesting() bool { return c.AppEnv == "testing" } + +func Load() (*Config, error) { + _ = godotenv.Load(findEnvFile()) + + cfg := &Config{ + AppName: getEnv("APP_NAME", "library"), + AppEnv: getEnv("APP_ENV", "development"), + Version: getEnv("APP_VERSION", "0.1.0"), + Port: getEnv("PORT", "8080"), + + DBUrl: getEnv("DATABASE_URL", ""), + DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), + DBConnMaxLifetime: getEnvDuration("DB_CONN_MAX_LIFETIME", 5*time.Minute), + DBConnMaxIdleTime: getEnvDuration("DB_CONN_MAX_IDLE_TIME", 1*time.Minute), + + JWTSecret: getEnv("JWT_SECRET", "secretLibrary"), + JWTExpiryHours: getEnvDuration("JWT_EXPIRY", 24*time.Hour), + + AccessTokenExpiry: getEnvDuration("ACCESS_TOKEN_EXPIRY", 30*time.Minute), + RefreshTokenExpiry: getEnvDuration("REFRESH_TOKEN_EXPIRY", 30*24*time.Hour), + + RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"), + + GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), + GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), + GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/portal/auth/google/callback"), + + OTPExpiry: getEnvDuration("OTP_EXPIRY", 1*time.Minute), + + ElasticsearchURL: getEnv("ELASTICSEARCH_URL", "http://localhost:9200"), + + RequestTimeout: getEnvDuration("REQUEST_TIMEOUT", 30*time.Second), + AllowedOrigins: getEnvSlice("ALLOWED_ORIGINS", []string{"http://localhost:3000"}), + RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100), + RateLimitWindow: getEnvDuration("RATE_LIMIT_WINDOW", 1*time.Minute), + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +func (c *Config) validate() error { + if c.Port == "" { + return fmt.Errorf("PORT must be set") + } + if c.DBUrl == "" { + return fmt.Errorf("DATABASE_URL must be set") + } + return nil +} + +func findEnvFile() string { + dir, _ := os.Getwd() + for { + p := dir + "/.env" + if _, err := os.Stat(p); err == nil { + return p + } + parent := dir + "/.." + abs, _ := filepath.Abs(parent) + if abs == dir { + break + } + dir = abs + } + return "" +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + var i int + _, err := fmt.Sscanf(v, "%d", &i) + if err == nil { + return i + } + fmt.Printf("Failed to parse int from %s: %v\n", key, err) + return fallback + } + return fallback +} + +func getEnvDuration(key string, fallback time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return fallback +} + +func getEnvSlice(key string, fallback []string) []string { + v := os.Getenv(key) + if v == "" { + return fallback + } + parts := strings.Split(v, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/configs/db.go b/configs/db.go new file mode 100644 index 0000000..a61ccb4 --- /dev/null +++ b/configs/db.go @@ -0,0 +1,44 @@ +package configs + +import ( + "fmt" + "log" + "time" + + "github.com/jmoiron/sqlx" +) + +func NewDB(cfg *Config) (*sqlx.DB, error) { + const ( + maxRetries = 10 + retryDelay = 2 * time.Second + ) + + var ( + db *sqlx.DB + err error + ) + + for attempt := 1; attempt <= maxRetries; attempt++ { + db, err = sqlx.Open("pgx", cfg.DBUrl) + if err != nil { + return nil, fmt.Errorf("failed to open db: %w", err) + } + + db.SetMaxOpenConns(cfg.DBMaxOpenConns) + db.SetMaxIdleConns(cfg.DBMaxIdleConns) + db.SetConnMaxLifetime(cfg.DBConnMaxLifetime) + db.SetConnMaxIdleTime(cfg.DBConnMaxIdleTime) + + if err = db.Ping(); err == nil { + log.Printf("[db] connected (attempt %d/%d)", attempt, maxRetries) + return db, nil + } + + log.Printf("[db] not ready, retrying in %s... (attempt %d/%d): %v", + retryDelay, attempt, maxRetries, err) + time.Sleep(retryDelay) + } + + return nil, fmt.Errorf("could not connect to postgres after %d attempts: %w", maxRetries, err) +} diff --git a/configs/redis.go b/configs/redis.go new file mode 100644 index 0000000..080525f --- /dev/null +++ b/configs/redis.go @@ -0,0 +1,41 @@ +package configs + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +func NewRedis(cfg *Config) (*redis.Client, error) { + const ( + maxRetries = 10 + retryDelay = 2 * time.Second + ) + + opts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + return nil, fmt.Errorf("invalid REDIS_URL: %w", err) + } + + client := redis.NewClient(opts) + + for attempt := 1; attempt <= maxRetries; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = client.Ping(ctx).Err() + cancel() + + if err == nil { + log.Printf("[redis] connected (attempt %d/%d)", attempt, maxRetries) + return client, nil + } + + log.Printf("[redis] not ready, retrying in %s... (attempt %d/%d): %v", + retryDelay, attempt, maxRetries, err) + time.Sleep(retryDelay) + } + + return nil, fmt.Errorf("could not connect to redis after %d attempts: %w", maxRetries, err) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73c7308 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,101 @@ +services: + + postgres: + image: postgres:16-alpine + container_name: library_postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-library} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} + POSTGRES_DB: ${POSTGRES_DB:-library} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-library} -d ${POSTGRES_DB:-library}"] + interval: 10s + timeout: 10s + retries: 10 + restart: unless-stopped + redis: + image: redis:7-alpine + container_name: library_redis + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + elasticsearch: + image: elasticsearch:8.13.0 + container_name: library_elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qE 'green|yellow'"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + api: + build: + context: . + dockerfile: Dockerfile + args: + VERSION: ${VERSION:-dev} + container_name: library_api + environment: + # Application + APP_NAME: ${APP_NAME:-library} + APP_ENV: ${APP_ENV:-development} + PORT: 8080 + + # Database - use Docker service names + DATABASE_URL: postgres://${POSTGRES_USER:-library}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-library}?sslmode=disable + + # Redis - use Docker service name (not localhost) + REDIS_URL: redis://redis:6379 + + # Elasticsearch - use Docker service name (not localhost) + ELASTICSEARCH_URL: http://elasticsearch:9200 + + # JWT + JWT_SECRET: ${JWT_SECRET:-secret} + ACCESS_TOKEN_EXPIRY: ${ACCESS_TOKEN_EXPIRY:-30} + REFRESH_TOKEN_EXPIRY: ${REFRESH_TOKEN_EXPIRY:-720} + + # OAuth + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + GOOGLE_REDIRECT_URL: ${GOOGLE_REDIRECT_URL:-http://localhost:8080/api/v1/portal/auth/google/callback} + + # OTP + OTP_EXPIRY: ${OTP_EXPIRY:-1} + + # Other + REQUEST_TIMEOUT: ${REQUEST_TIMEOUT:-30s} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173} + RATE_LIMIT_REQUESTS: ${RATE_LIMIT_REQUESTS:-100} + RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-1m} + ports: + - "${HOST_PORT:-8080}:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + elasticsearch: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: + elasticsearch_data: \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..20685ad --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Architecture + +## Layered Architecture + +``` +Request + │ + ▼ +Middleware (timeout → security → CORS → logger → recovery → rate limit) + │ + ▼ +Handler (parse request → validate input → call service) + │ + ▼ +Service (business rules → orchestrate repositories) + │ + ├── Repository (SQL queries → PostgreSQL) + ├── Search (Elasticsearch — book indexing + full-text search) + └── Cache (Redis — sessions, OTP, report caching) +``` + +## Request Lifecycle + +1. **Timeout middleware** — sets a 30s deadline on the context +2. **Security/CORS** — headers and origin validation +3. **Logger** — structured request log +4. **Rate limiter** — Redis-backed IP limiting +5. **Auth middleware** — validates JWT + Redis session check +6. **Role middleware** — checks `SubjectType` and `Role.AtLeast()` +7. **Handler** — decodes body, runs validator, calls service +8. **Service** — enforces business rules, coordinates repos +9. **Repository** — SQL query, returns domain entity or AppError + +## Error Propagation + +``` +Repository → AppError(code, type, context) + ↓ +Service → wraps or re-raises, adds business context + ↓ +Handler → response.HandleAppError → HTTP status from AppError.Type() + ↓ +Client → { error: { code, message, fields? }, meta: { messageCode, ... } } +``` + +## Two API Surfaces + +``` +/api/v1/backoffice/ employee JWT + role ≥ manager +/api/v1/portal/ customer JWT (auth routes) or no auth (book browse) +/api/v1/search/ no auth (public search) +/api/v1/health no auth (monitoring) +``` diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..33caa3e --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,3957 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": { + "name": "Mohammad Farrokhnia", + "url": "https://github.com/mohammad-farrokhnia", + "email": "mohammad.farokhnia.a@gmail.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/backoffice/auth/forgot-password": { + "post": { + "description": "Sends an OTP to the employee's email. Always returns success.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Forgot Password", + "parameters": [ + { + "description": "Email", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/login": { + "post": { + "description": "Authenticate via email or mobile + password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Employee Login", + "parameters": [ + { + "description": "Login credentials", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_handler.employeeLoginResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/logout": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Invalidates the current session.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Employee Logout", + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/refresh": { + "post": { + "description": "Issues a new token pair using a valid refresh token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Refresh Access Token", + "parameters": [ + { + "description": "Refresh token", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/reset-password": { + "post": { + "description": "Verifies OTP and updates the employee's password. Invalidates all active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Reset Password", + "parameters": [ + { + "description": "OTP and new password", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all books in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (title, author, genre, publicationYear, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in title, author, or genre", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "List All Books", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Books retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new book in the library. ISBN is optional (for old books without ISBN) but must be unique if provided.\n\n**Required Fields:**\n- title\n- author\n- language\n- publisher\n- pages\n\n**Optional Fields:**\n- isbn (must be unique if provided)\n- genre\n- publicationYear\n- description\n- totalCopies (defaults to 1 if not provided)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Create Book", + "parameters": [ + { + "description": "Book details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput" + } + } + ], + "responses": { + "201": { + "description": "Book created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "ISBN already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific book by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Get Book by ID", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Book retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing book. At least one field must be provided.\n\n**Updatable Fields:**\n- genre\n- description\n- publisher\n\n**Business Rules:**\n- At least one field must be provided\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Update Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput" + } + } + ], + "responses": { + "200": { + "description": "Book updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or no fields provided", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes a book from the library by its UUID.\n\n**Business Rules:**\n- Book must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Delete Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Book deleted successfully" + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/copies/add": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Adds additional copies of a book to the library inventory. This increases both totalCopies and availableCopies by the specified quantity.\n\n**Business Rules:**\n- Quantity must be at least 1\n- Both total and available copies increase by the same amount\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Add Copies to Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Number of copies to add", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput" + } + } + ], + "responses": { + "200": { + "description": "Copies added successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/copies/remove": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Removes copies of a book from the library inventory. This decreases both totalCopies and availableCopies by the specified quantity.\n\n**Business Rules:**\n- Quantity must be at least 1\n- Cannot remove more copies than are currently available\n- Both total and available copies decrease by the same amount\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Remove Copies from Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Number of copies to remove", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput" + } + } + ], + "responses": { + "200": { + "description": "Copies removed successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or insufficient copies", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/recommendations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns full book data for recommendations alongside this one.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/recommendations" + ], + "summary": "Book Recommendations (Backoffice)", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves all borrow records with pagination, sorting, and search.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (borrowedAt, dueDate, status, createdAt)\n- order: Sort order (asc, desc)\n- search: Search by customer name or book title", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "List All Borrows", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new borrow record.\n\n**Business Rules:**\n- Customer must be active\n- Book must have available copies\n- Customer cannot exceed 3 active borrows\n- Customer cannot borrow the same book twice simultaneously\n- Default loan period is 14 days", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "Borrow a Book", + "parameters": [ + { + "description": "Borrow details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows/customers/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all currently active borrows for a specific customer.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "List Customer's Active Borrows", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows/{id}/return": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Marks a borrow as returned and restores the book's available copies.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "Return a Book", + "parameters": [ + { + "type": "string", + "description": "Borrow UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/customers": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all customers in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (firstName, lastName, email, mobile, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in firstName, lastName, email, or mobile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "List All Customers", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Customers retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new customer in the library. A default password will be set automatically.\n\n**Required Fields:**\n- firstName\n- lastName\n- nationalCode\n- email\n- birthDate (ISO 8601 date format: YYYY-MM-DD)\n\n**Optional Fields:**\n- mobile (must be unique if provided)\n- address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Create Customer", + "parameters": [ + { + "description": "Customer details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput" + } + } + ], + "responses": { + "201": { + "description": "Customer created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email or mobile already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/customers/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific customer by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Get Customer by ID", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Customer retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing customer. Only address and active fields can be updated.\n\n**Updatable Fields:**\n- address\n- active\n\n**Business Rules:**\n- Customer must exist in the system\n- Other fields (firstName, lastName, email, mobile, nationalCode, birthDate) cannot be updated", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Update Customer", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput" + } + } + ], + "responses": { + "200": { + "description": "Customer updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes a customer from the library by its UUID.\n\n**Business Rules:**\n- Customer must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Delete Customer", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Customer deleted successfully" + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/employees": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all employees in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (firstName, lastName, email, role, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in firstName, lastName, or email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "List All Employees", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Employees retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new employee in the library.\n\n**Required Fields:**\n- firstName\n- lastName\n- email\n- mobile\n- nationalCode\n- birthDate (ISO 8601 date format: YYYY-MM-DD)\n- password (min 8 characters)\n\n**Optional Fields:**\n- role (defaults to librarian if not provided)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Create Employee", + "parameters": [ + { + "description": "Employee details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput" + } + } + ], + "responses": { + "201": { + "description": "Employee created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email, mobile, or national code already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/employees/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific employee by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Get Employee by ID", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Employee retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing employee.\n\n**Updatable Fields:**\n- email\n- role\n- active\n\n**Business Rules:**\n- Employee must exist in the system\n- Email must be unique if updated", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Update Employee", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput" + } + } + ], + "responses": { + "200": { + "description": "Employee updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes an employee from the library by its UUID.\n\n**Business Rules:**\n- Employee must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Delete Employee", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Employee deleted successfully" + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/reports/books/low-availability": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Books where available copies are at or below the threshold.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Low Availability Books", + "parameters": [ + { + "type": "integer", + "description": "Available copies threshold (default: 2)", + "name": "threshold", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/books/top": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Books ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Top Borrowed Books", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10, max: 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopBook" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/borrows/overdue": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "All currently overdue borrows, ordered by most overdue first.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Overdue Borrows", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/borrows/trends": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Borrow counts grouped by day, week, or month over a date range.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Borrow Trends", + "parameters": [ + { + "type": "string", + "description": "daily | weekly | monthly (default: monthly)", + "name": "period", + "in": "query" + }, + { + "type": "string", + "description": "Start date YYYY-MM-DD (default: 30 days ago)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End date YYYY-MM-DD (default: today)", + "name": "to", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/customers/top": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Customers ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Most Active Customers", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10, max: 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/genres/popularity": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Genres ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Genre Popularity", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/summary/monthly": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Total borrows, returns, overdue, new customers, and new books for a month.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Monthly Summary", + "parameters": [ + { + "type": "string", + "description": "Month in YYYY-MM format (default: current month)", + "name": "month", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary" + } + } + } + ] + } + } + } + } + }, + "/backoffice/search/books": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Full-text search returning complete book data including ISBN and copy counts.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/search" + ], + "summary": "Backoffice Book Search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy matching", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns the live status of the API and all dependencies.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "All systems operational", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + }, + "206": { + "description": "Partial — some non-critical services degraded", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + }, + "503": { + "description": "Critical dependency unavailable", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + } + } + } + }, + "/portal/auth/forgot-password": { + "post": { + "description": "Sends an OTP to the customer's email. Always returns success for security.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Forgot Password", + "parameters": [ + { + "description": "Email address", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput" + } + } + ], + "responses": { + "200": { + "description": "OTP sent successfully", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "400": { + "description": "Invalid email format", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/google": { + "get": { + "description": "Redirects customer to Google OAuth consent screen.", + "produces": [ + "text/html" + ], + "tags": [ + "portal/auth" + ], + "summary": "Google OAuth Redirect", + "responses": { + "302": { + "description": "Redirect to Google OAuth" + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/google/callback": { + "get": { + "description": "Handles Google OAuth callback and authenticates customer.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Google OAuth Callback", + "parameters": [ + { + "type": "string", + "description": "Authorization code from Google", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Authentication successful", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse" + } + } + } + ] + } + }, + "400": { + "description": "Missing authorization code", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Authentication failed", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/login": { + "post": { + "description": "Authenticates a customer using email or mobile with password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Login", + "parameters": [ + { + "description": "Login credentials", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput" + } + } + ], + "responses": { + "200": { + "description": "Login successful", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/logout": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Invalidates the current customer session.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Logout", + "responses": { + "204": { + "description": "Logout successful" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/refresh": { + "post": { + "description": "Issues a new token pair using a valid refresh token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Refresh Customer Token", + "parameters": [ + { + "description": "Refresh token", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput" + } + } + ], + "responses": { + "200": { + "description": "Token refreshed successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid or expired refresh token", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/reset-password": { + "post": { + "description": "Verifies OTP and updates the customer's password. Invalidates all active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Reset Password", + "parameters": [ + { + "description": "OTP and new password", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput" + } + } + ], + "responses": { + "204": { + "description": "Password reset successfully" + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid or expired OTP", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/signup": { + "post": { + "description": "Creates a new customer account with email/mobile and password.\n**Required Fields:**\n- firstName\n- lastName\n- email\n- mobile\n- password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Signup", + "parameters": [ + { + "description": "Signup details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput" + } + } + ], + "responses": { + "201": { + "description": "Account created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email or mobile already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books": { + "get": { + "description": "Paginated list of books with public info. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Browse Books", + "parameters": [ + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "asc | desc", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + } + } + } + } + ] + } + } + } + } + }, + "/portal/books/search": { + "get": { + "description": "Full-text Elasticsearch search returning public book info.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Search Books (Portal)", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/suggest": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Book Autocomplete (Portal)", + "parameters": [ + { + "type": "string", + "description": "Partial query (min 2 chars)", + "name": "q", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/{id}": { + "get": { + "description": "Returns public book info by ID. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Get Book Detail", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/{id}/recommendations": { + "get": { + "description": "Returns books frequently borrowed alongside this one. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/recommendations" + ], + "summary": "Book Recommendations (Portal)", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns the authenticated customer's profile.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/me" + ], + "summary": "Get My Profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Customers can update their birth date and national code.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/me" + ], + "summary": "Update My Profile", + "parameters": [ + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + } + } + } + }, + "/portal/me/borrows": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all borrows for the authenticated customer with optional status filter.\n\n**Query Parameters:**\n- status: Filter by status (active | returned | overdue). Omit for all.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/borrows" + ], + "summary": "My Borrow History", + "parameters": [ + { + "type": "string", + "description": "active | returned | overdue", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + } + } + } + }, + "/portal/me/recommendations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns books tailored to the authenticated customer's borrowing history.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/recommendations" + ], + "summary": "Personal Recommendations", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/search/books": { + "get": { + "description": "Full-text search with optional fuzzy matching. Returns limited book info.", + "produces": [ + "application/json" + ], + "tags": [ + "search" + ], + "summary": "Public Book Search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy matching", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/search/books/suggest": { + "get": { + "description": "Returns title and author suggestions for a partial query.", + "produces": [ + "application/json" + ], + "tags": [ + "search" + ], + "summary": "Book Autocomplete", + "parameters": [ + { + "type": "string", + "description": "Partial search term (min 2 chars)", + "name": "q", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + } + }, + "definitions": { + "github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + }, + "score": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Book": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Borrow": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "bookIsbn": { + "type": "string" + }, + "bookTitle": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus": { + "type": "string", + "enum": [ + "active", + "returned", + "overdue" + ], + "x-enum-varnames": [ + "BorrowStatusActive", + "BorrowStatusReturned", + "BorrowStatusOverdue" + ] + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "period": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "customerId": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "googleId": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput": { + "type": "object", + "properties": { + "birthDate": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailShowcase": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Customer": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "handler": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "handler": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity": { + "type": "object", + "properties": { + "borrowCount": { + "type": "integer" + }, + "genre": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse": { + "type": "object", + "properties": { + "customer": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + }, + "tokens": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "availablePercent": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "newBooks": { + "type": "integer" + }, + "newCustomers": { + "type": "integer" + }, + "overdueBorrows": { + "type": "integer" + }, + "totalBorrows": { + "type": "integer" + }, + "totalReturns": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "bookIsbn": { + "type": "string" + }, + "bookTitle": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "daysOverdue": { + "type": "integer" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.PublicBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAvailable": { + "type": "boolean" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Recommendation": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + }, + "reason": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "newPassword": { + "type": "string" + }, + "otp": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Role": { + "type": "string", + "enum": [ + "librarian", + "manager", + "super_admin" + ], + "x-enum-varnames": [ + "RoleLibrarian", + "RoleManager", + "RoleSuperAdmin" + ] + }, + "github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TokenPair": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TopBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "borrowCount": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer": { + "type": "object", + "properties": { + "borrowCount": { + "type": "integer" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "publisher": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput": { + "type": "object", + "properties": { + "birthDate": { + "type": "string" + }, + "nationalCode": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + } + } + }, + "github_com_mohammad-farrokhnia_library_pkg_response.Meta": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "example": "library" + }, + "message": { + "type": "string", + "example": "Service is up and running." + }, + "messageCode": { + "type": "string", + "example": "HEALTH_OK" + }, + "requestId": { + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "timestamp": { + "type": "string", + "example": "2024-01-01T00:00:00Z" + }, + "version": { + "type": "string", + "example": "1.0" + } + } + }, + "github_com_mohammad-farrokhnia_library_pkg_response.Response": { + "type": "object", + "properties": { + "data": {}, + "meta": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Meta" + } + } + }, + "internal_handler.employeeLoginResponse": { + "type": "object", + "properties": { + "employee": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + }, + "tokens": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + }, + "internal_handler.healthCheck": { + "type": "object", + "properties": { + "app": { + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "Bearer": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "Library API", + Description: "A comprehensive library management system API with full CRUD operations for books, authors, and members. This API provides a robust foundation for managing library resources with support for internationalization, detailed error handling, and comprehensive logging.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..54fae4d --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,3933 @@ +{ + "swagger": "2.0", + "info": { + "description": "A comprehensive library management system API with full CRUD operations for books, authors, and members. This API provides a robust foundation for managing library resources with support for internationalization, detailed error handling, and comprehensive logging.", + "title": "Library API", + "contact": { + "name": "Mohammad Farrokhnia", + "url": "https://github.com/mohammad-farrokhnia", + "email": "mohammad.farokhnia.a@gmail.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/api/v1", + "paths": { + "/backoffice/auth/forgot-password": { + "post": { + "description": "Sends an OTP to the employee's email. Always returns success.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Forgot Password", + "parameters": [ + { + "description": "Email", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/login": { + "post": { + "description": "Authenticate via email or mobile + password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Employee Login", + "parameters": [ + { + "description": "Login credentials", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_handler.employeeLoginResponse" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/logout": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Invalidates the current session.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Employee Logout", + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/refresh": { + "post": { + "description": "Issues a new token pair using a valid refresh token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Refresh Access Token", + "parameters": [ + { + "description": "Refresh token", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/auth/reset-password": { + "post": { + "description": "Verifies OTP and updates the employee's password. Invalidates all active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/auth" + ], + "summary": "Reset Password", + "parameters": [ + { + "description": "OTP and new password", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all books in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (title, author, genre, publicationYear, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in title, author, or genre", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "List All Books", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Books retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new book in the library. ISBN is optional (for old books without ISBN) but must be unique if provided.\n\n**Required Fields:**\n- title\n- author\n- language\n- publisher\n- pages\n\n**Optional Fields:**\n- isbn (must be unique if provided)\n- genre\n- publicationYear\n- description\n- totalCopies (defaults to 1 if not provided)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Create Book", + "parameters": [ + { + "description": "Book details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput" + } + } + ], + "responses": { + "201": { + "description": "Book created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "ISBN already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific book by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Get Book by ID", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Book retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing book. At least one field must be provided.\n\n**Updatable Fields:**\n- genre\n- description\n- publisher\n\n**Business Rules:**\n- At least one field must be provided\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Update Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput" + } + } + ], + "responses": { + "200": { + "description": "Book updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or no fields provided", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes a book from the library by its UUID.\n\n**Business Rules:**\n- Book must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Delete Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Book deleted successfully" + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/copies/add": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Adds additional copies of a book to the library inventory. This increases both totalCopies and availableCopies by the specified quantity.\n\n**Business Rules:**\n- Quantity must be at least 1\n- Both total and available copies increase by the same amount\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Add Copies to Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Number of copies to add", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput" + } + } + ], + "responses": { + "200": { + "description": "Copies added successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/copies/remove": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Removes copies of a book from the library inventory. This decreases both totalCopies and availableCopies by the specified quantity.\n\n**Business Rules:**\n- Quantity must be at least 1\n- Cannot remove more copies than are currently available\n- Both total and available copies decrease by the same amount\n- Book must exist in the system", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/books" + ], + "summary": "Remove Copies from Book", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Number of copies to remove", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput" + } + } + ], + "responses": { + "200": { + "description": "Copies removed successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or insufficient copies", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Book not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/books/{id}/recommendations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns full book data for recommendations alongside this one.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/recommendations" + ], + "summary": "Book Recommendations (Backoffice)", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves all borrow records with pagination, sorting, and search.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (borrowedAt, dueDate, status, createdAt)\n- order: Sort order (asc, desc)\n- search: Search by customer name or book title", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "List All Borrows", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new borrow record.\n\n**Business Rules:**\n- Customer must be active\n- Book must have available copies\n- Customer cannot exceed 3 active borrows\n- Customer cannot borrow the same book twice simultaneously\n- Default loan period is 14 days", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "Borrow a Book", + "parameters": [ + { + "description": "Borrow details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows/customers/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all currently active borrows for a specific customer.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "List Customer's Active Borrows", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/borrows/{id}/return": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Marks a borrow as returned and restores the book's available copies.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/borrows" + ], + "summary": "Return a Book", + "parameters": [ + { + "type": "string", + "description": "Borrow UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/customers": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all customers in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (firstName, lastName, email, mobile, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in firstName, lastName, email, or mobile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "List All Customers", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Customers retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new customer in the library. A default password will be set automatically.\n\n**Required Fields:**\n- firstName\n- lastName\n- nationalCode\n- email\n- birthDate (ISO 8601 date format: YYYY-MM-DD)\n\n**Optional Fields:**\n- mobile (must be unique if provided)\n- address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Create Customer", + "parameters": [ + { + "description": "Customer details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput" + } + } + ], + "responses": { + "201": { + "description": "Customer created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email or mobile already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/customers/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific customer by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Get Customer by ID", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Customer retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing customer. Only address and active fields can be updated.\n\n**Updatable Fields:**\n- address\n- active\n\n**Business Rules:**\n- Customer must exist in the system\n- Other fields (firstName, lastName, email, mobile, nationalCode, birthDate) cannot be updated", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Update Customer", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput" + } + } + ], + "responses": { + "200": { + "description": "Customer updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes a customer from the library by its UUID.\n\n**Business Rules:**\n- Customer must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/customers" + ], + "summary": "Delete Customer", + "parameters": [ + { + "type": "string", + "description": "Customer UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Customer deleted successfully" + }, + "404": { + "description": "Customer not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/employees": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a list of all employees in the library with pagination, sorting, and filtering.\n\n**Query Parameters:**\n- page: Page number (default: 1)\n- size: Items per page (default: 10, max: 100)\n- sort: Field to sort by (firstName, lastName, email, role, createdAt)\n- order: Sort order (asc, desc, default: desc)\n- search: Search in firstName, lastName, or email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "List All Employees", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Sort order", + "name": "order", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Employees retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Creates a new employee in the library.\n\n**Required Fields:**\n- firstName\n- lastName\n- email\n- mobile\n- nationalCode\n- birthDate (ISO 8601 date format: YYYY-MM-DD)\n- password (min 8 characters)\n\n**Optional Fields:**\n- role (defaults to librarian if not provided)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Create Employee", + "parameters": [ + { + "description": "Employee details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput" + } + } + ], + "responses": { + "201": { + "description": "Employee created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email, mobile, or national code already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/employees/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Retrieves a specific employee by its UUID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Get Employee by ID", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Employee retrieved successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Updates specific fields of an existing employee.\n\n**Updatable Fields:**\n- email\n- role\n- active\n\n**Business Rules:**\n- Employee must exist in the system\n- Email must be unique if updated", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Update Employee", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput" + } + } + ], + "responses": { + "200": { + "description": "Employee updated successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Permanently deletes an employee from the library by its UUID.\n\n**Business Rules:**\n- Employee must exist in the system\n- Deletion is permanent and cannot be undone", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/employees" + ], + "summary": "Delete Employee", + "parameters": [ + { + "type": "string", + "description": "Employee UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Employee deleted successfully" + }, + "404": { + "description": "Employee not found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/backoffice/reports/books/low-availability": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Books where available copies are at or below the threshold.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Low Availability Books", + "parameters": [ + { + "type": "integer", + "description": "Available copies threshold (default: 2)", + "name": "threshold", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/books/top": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Books ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Top Borrowed Books", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10, max: 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopBook" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/borrows/overdue": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "All currently overdue borrows, ordered by most overdue first.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Overdue Borrows", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/borrows/trends": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Borrow counts grouped by day, week, or month over a date range.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Borrow Trends", + "parameters": [ + { + "type": "string", + "description": "daily | weekly | monthly (default: monthly)", + "name": "period", + "in": "query" + }, + { + "type": "string", + "description": "Start date YYYY-MM-DD (default: 30 days ago)", + "name": "from", + "in": "query" + }, + { + "type": "string", + "description": "End date YYYY-MM-DD (default: today)", + "name": "to", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/customers/top": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Customers ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Most Active Customers", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10, max: 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/genres/popularity": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Genres ranked by total borrow count.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Genre Popularity", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity" + } + } + } + } + ] + } + } + } + } + }, + "/backoffice/reports/summary/monthly": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Total borrows, returns, overdue, new customers, and new books for a month.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/reports" + ], + "summary": "Monthly Summary", + "parameters": [ + { + "type": "string", + "description": "Month in YYYY-MM format (default: current month)", + "name": "month", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary" + } + } + } + ] + } + } + } + } + }, + "/backoffice/search/books": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Full-text search returning complete book data including ISBN and copy counts.", + "produces": [ + "application/json" + ], + "tags": [ + "backoffice/search" + ], + "summary": "Backoffice Book Search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy matching", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns the live status of the API and all dependencies.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health Check", + "responses": { + "200": { + "description": "All systems operational", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + }, + "206": { + "description": "Partial — some non-critical services degraded", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + }, + "503": { + "description": "Critical dependency unavailable", + "schema": { + "$ref": "#/definitions/internal_handler.healthCheck" + } + } + } + } + }, + "/portal/auth/forgot-password": { + "post": { + "description": "Sends an OTP to the customer's email. Always returns success for security.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Forgot Password", + "parameters": [ + { + "description": "Email address", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput" + } + } + ], + "responses": { + "200": { + "description": "OTP sent successfully", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "400": { + "description": "Invalid email format", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/google": { + "get": { + "description": "Redirects customer to Google OAuth consent screen.", + "produces": [ + "text/html" + ], + "tags": [ + "portal/auth" + ], + "summary": "Google OAuth Redirect", + "responses": { + "302": { + "description": "Redirect to Google OAuth" + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/google/callback": { + "get": { + "description": "Handles Google OAuth callback and authenticates customer.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Google OAuth Callback", + "parameters": [ + { + "type": "string", + "description": "Authorization code from Google", + "name": "code", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "Authentication successful", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse" + } + } + } + ] + } + }, + "400": { + "description": "Missing authorization code", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Authentication failed", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/login": { + "post": { + "description": "Authenticates a customer using email or mobile with password.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Login", + "parameters": [ + { + "description": "Login credentials", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput" + } + } + ], + "responses": { + "200": { + "description": "Login successful", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/logout": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Invalidates the current customer session.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Logout", + "responses": { + "204": { + "description": "Logout successful" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/refresh": { + "post": { + "description": "Issues a new token pair using a valid refresh token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Refresh Customer Token", + "parameters": [ + { + "description": "Refresh token", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput" + } + } + ], + "responses": { + "200": { + "description": "Token refreshed successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid or expired refresh token", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/reset-password": { + "post": { + "description": "Verifies OTP and updates the customer's password. Invalidates all active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Reset Password", + "parameters": [ + { + "description": "OTP and new password", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput" + } + } + ], + "responses": { + "204": { + "description": "Password reset successfully" + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "401": { + "description": "Invalid or expired OTP", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/auth/signup": { + "post": { + "description": "Creates a new customer account with email/mobile and password.\n**Required Fields:**\n- firstName\n- lastName\n- email\n- mobile\n- password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/auth" + ], + "summary": "Customer Signup", + "parameters": [ + { + "description": "Signup details", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput" + } + } + ], + "responses": { + "201": { + "description": "Account created successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + }, + "400": { + "description": "Invalid request or validation error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "409": { + "description": "Email or mobile already exists", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books": { + "get": { + "description": "Paginated list of books with public info. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Browse Books", + "parameters": [ + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + }, + { + "type": "string", + "description": "Sort field", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "asc | desc", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + } + } + } + } + ] + } + } + } + } + }, + "/portal/books/search": { + "get": { + "description": "Full-text Elasticsearch search returning public book info.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Search Books (Portal)", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/suggest": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Book Autocomplete (Portal)", + "parameters": [ + { + "type": "string", + "description": "Partial query (min 2 chars)", + "name": "q", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/{id}": { + "get": { + "description": "Returns public book info by ID. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/books" + ], + "summary": "Get Book Detail", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/books/{id}/recommendations": { + "get": { + "description": "Returns books frequently borrowed alongside this one. No auth required.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/recommendations" + ], + "summary": "Book Recommendations (Portal)", + "parameters": [ + { + "type": "string", + "description": "Book UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/portal/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns the authenticated customer's profile.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/me" + ], + "summary": "Get My Profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Customers can update their birth date and national code.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "portal/me" + ], + "summary": "Update My Profile", + "parameters": [ + { + "description": "Fields to update", + "name": "input", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + } + } + } + ] + } + } + } + } + }, + "/portal/me/borrows": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns all borrows for the authenticated customer with optional status filter.\n\n**Query Parameters:**\n- status: Filter by status (active | returned | overdue). Omit for all.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/borrows" + ], + "summary": "My Borrow History", + "parameters": [ + { + "type": "string", + "description": "active | returned | overdue", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "Page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail" + } + } + } + } + ] + } + } + } + } + }, + "/portal/me/recommendations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Returns books tailored to the authenticated customer's borrowing history.", + "produces": [ + "application/json" + ], + "tags": [ + "portal/recommendations" + ], + "summary": "Personal Recommendations", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/search/books": { + "get": { + "description": "Full-text search with optional fuzzy matching. Returns limited book info.", + "produces": [ + "application/json" + ], + "tags": [ + "search" + ], + "summary": "Public Book Search", + "parameters": [ + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "boolean", + "description": "Enable fuzzy matching", + "name": "fuzzy", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + }, + "/search/books/suggest": { + "get": { + "description": "Returns title and author suggestions for a partial query.", + "produces": [ + "application/json" + ], + "tags": [ + "search" + ], + "summary": "Book Autocomplete", + "parameters": [ + { + "type": "string", + "description": "Partial search term (min 2 chars)", + "name": "q", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response" + } + } + } + } + } + }, + "definitions": { + "github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book" + }, + "score": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Book": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Borrow": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "bookIsbn": { + "type": "string" + }, + "bookTitle": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus": { + "type": "string", + "enum": [ + "active", + "returned", + "overdue" + ], + "x-enum-varnames": [ + "BorrowStatusActive", + "BorrowStatusReturned", + "BorrowStatusOverdue" + ] + }, + "github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "period": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "customerId": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "googleId": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput": { + "type": "object", + "properties": { + "birthDate": { + "type": "string" + }, + "email": { + "type": "string" + }, + "emailShowcase": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Customer": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "handler": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "handler": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity": { + "type": "object", + "properties": { + "borrowCount": { + "type": "integer" + }, + "genre": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse": { + "type": "object", + "properties": { + "customer": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer" + }, + "tokens": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "availablePercent": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "newBooks": { + "type": "integer" + }, + "newCustomers": { + "type": "integer" + }, + "overdueBorrows": { + "type": "integer" + }, + "totalBorrows": { + "type": "integer" + }, + "totalReturns": { + "type": "integer" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow": { + "type": "object", + "properties": { + "bookId": { + "type": "string" + }, + "bookIsbn": { + "type": "string" + }, + "bookTitle": { + "type": "string" + }, + "borrowedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "daysOverdue": { + "type": "integer" + }, + "dueDate": { + "type": "string" + }, + "id": { + "type": "string" + }, + "returnedAt": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.PublicBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAvailable": { + "type": "boolean" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Recommendation": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook" + }, + "reason": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput": { + "type": "object", + "required": [ + "quantity" + ], + "properties": { + "quantity": { + "type": "integer", + "minimum": 1 + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "newPassword": { + "type": "string" + }, + "otp": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.Role": { + "type": "string", + "enum": [ + "librarian", + "manager", + "super_admin" + ], + "x-enum-varnames": [ + "RoleLibrarian", + "RoleManager", + "RoleSuperAdmin" + ] + }, + "github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + }, + "authProvider": { + "type": "string" + }, + "birthDate": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "nationalCode": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TokenPair": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + }, + "token_type": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TopBook": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "availableCopies": { + "type": "integer" + }, + "borrowCount": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isbn": { + "type": "string" + }, + "language": { + "type": "string" + }, + "pages": { + "type": "integer" + }, + "publicationYear": { + "type": "integer" + }, + "publisher": { + "type": "string" + }, + "title": { + "type": "string" + }, + "totalCopies": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer": { + "type": "object", + "properties": { + "borrowCount": { + "type": "integer" + }, + "customerId": { + "type": "string" + }, + "customerName": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "publisher": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "address": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput": { + "type": "object", + "properties": { + "birthDate": { + "type": "string" + }, + "nationalCode": { + "type": "string" + } + } + }, + "github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "mobile": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role" + } + } + }, + "github_com_mohammad-farrokhnia_library_pkg_response.Meta": { + "type": "object", + "properties": { + "appName": { + "type": "string", + "example": "library" + }, + "message": { + "type": "string", + "example": "Service is up and running." + }, + "messageCode": { + "type": "string", + "example": "HEALTH_OK" + }, + "requestId": { + "type": "string", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "timestamp": { + "type": "string", + "example": "2024-01-01T00:00:00Z" + }, + "version": { + "type": "string", + "example": "1.0" + } + } + }, + "github_com_mohammad-farrokhnia_library_pkg_response.Response": { + "type": "object", + "properties": { + "data": {}, + "meta": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Meta" + } + } + }, + "internal_handler.employeeLoginResponse": { + "type": "object", + "properties": { + "employee": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee" + }, + "tokens": { + "$ref": "#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair" + } + } + }, + "internal_handler.healthCheck": { + "type": "object", + "properties": { + "app": { + "type": "string" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "Bearer": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..74e35a3 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,2585 @@ +basePath: /api/v1 +definitions: + github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput: + properties: + quantity: + minimum: 1 + type: integer + required: + - quantity + type: object + github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation: + properties: + book: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + score: + type: integer + type: object + github_com_mohammad-farrokhnia_library_internal_domain.Book: + properties: + author: + type: string + availableCopies: + type: integer + createdAt: + type: string + description: + type: string + genre: + type: string + id: + type: string + isbn: + type: string + language: + type: string + pages: + type: integer + publicationYear: + type: integer + publisher: + type: string + title: + type: string + totalCopies: + type: integer + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.Borrow: + properties: + bookId: + type: string + borrowedAt: + type: string + createdAt: + type: string + customerId: + type: string + dueDate: + type: string + id: + type: string + returnedAt: + type: string + status: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus' + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail: + properties: + bookId: + type: string + bookIsbn: + type: string + bookTitle: + type: string + borrowedAt: + type: string + createdAt: + type: string + customerId: + type: string + customerName: + type: string + dueDate: + type: string + id: + type: string + returnedAt: + type: string + status: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus' + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus: + enum: + - active + - returned + - overdue + type: string + x-enum-varnames: + - BorrowStatusActive + - BorrowStatusReturned + - BorrowStatusOverdue + github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend: + properties: + count: + type: integer + period: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput: + properties: + author: + type: string + description: + type: string + genre: + type: string + isbn: + type: string + language: + type: string + pages: + type: integer + publicationYear: + type: integer + publisher: + type: string + title: + type: string + totalCopies: + type: integer + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput: + properties: + bookId: + type: string + customerId: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput: + properties: + address: + type: string + authProvider: + type: string + birthDate: + type: string + email: + type: string + firstName: + type: string + googleId: + type: string + lastName: + type: string + mobile: + type: string + nationalCode: + type: string + password: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput: + properties: + birthDate: + type: string + email: + type: string + emailShowcase: + type: string + firstName: + type: string + lastName: + type: string + mobile: + type: string + nationalCode: + type: string + password: + type: string + role: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role' + type: object + github_com_mohammad-farrokhnia_library_internal_domain.Customer: + properties: + active: + type: boolean + address: + type: string + authProvider: + type: string + birthDate: + type: string + createdAt: + type: string + email: + type: string + firstName: + type: string + id: + type: string + lastName: + type: string + mobile: + type: string + nationalCode: + type: string + password: + type: string + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput: + properties: + email: + type: string + handler: + type: string + mobile: + type: string + password: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput: + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + mobile: + type: string + password: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput: + properties: + email: + type: string + handler: + type: string + mobile: + type: string + password: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput: + properties: + email: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity: + properties: + borrowCount: + type: integer + genre: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse: + properties: + customer: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer' + tokens: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair' + type: object + github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook: + properties: + author: + type: string + availableCopies: + type: integer + availablePercent: + type: number + createdAt: + type: string + description: + type: string + genre: + type: string + id: + type: string + isbn: + type: string + language: + type: string + pages: + type: integer + publicationYear: + type: integer + publisher: + type: string + title: + type: string + totalCopies: + type: integer + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary: + properties: + month: + type: string + newBooks: + type: integer + newCustomers: + type: integer + overdueBorrows: + type: integer + totalBorrows: + type: integer + totalReturns: + type: integer + type: object + github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow: + properties: + bookId: + type: string + bookIsbn: + type: string + bookTitle: + type: string + borrowedAt: + type: string + createdAt: + type: string + customerId: + type: string + customerName: + type: string + daysOverdue: + type: integer + dueDate: + type: string + id: + type: string + returnedAt: + type: string + status: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowStatus' + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.PublicBook: + properties: + author: + type: string + description: + type: string + genre: + type: string + id: + type: string + isAvailable: + type: boolean + language: + type: string + pages: + type: integer + publicationYear: + type: integer + publisher: + type: string + title: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.Recommendation: + properties: + book: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook' + reason: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput: + properties: + refreshToken: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput: + properties: + quantity: + minimum: 1 + type: integer + required: + - quantity + type: object + github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput: + properties: + email: + type: string + newPassword: + type: string + otp: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.Role: + enum: + - librarian + - manager + - super_admin + type: string + x-enum-varnames: + - RoleLibrarian + - RoleManager + - RoleSuperAdmin + github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer: + properties: + active: + type: boolean + address: + type: string + authProvider: + type: string + birthDate: + type: string + createdAt: + type: string + email: + type: string + firstName: + type: string + id: + type: string + lastName: + type: string + mobile: + type: string + nationalCode: + type: string + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee: + properties: + active: + type: boolean + createdAt: + type: string + email: + type: string + firstName: + type: string + id: + type: string + lastName: + type: string + mobile: + type: string + role: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role' + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.TokenPair: + properties: + access_token: + type: string + expires_in: + type: integer + refresh_token: + type: string + token_type: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.TopBook: + properties: + author: + type: string + availableCopies: + type: integer + borrowCount: + type: integer + createdAt: + type: string + description: + type: string + genre: + type: string + id: + type: string + isbn: + type: string + language: + type: string + pages: + type: integer + publicationYear: + type: integer + publisher: + type: string + title: + type: string + totalCopies: + type: integer + updatedAt: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer: + properties: + borrowCount: + type: integer + customerId: + type: string + customerName: + type: string + email: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput: + properties: + description: + type: string + genre: + type: string + publisher: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput: + properties: + active: + type: boolean + address: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput: + properties: + birthDate: + type: string + nationalCode: + type: string + type: object + github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput: + properties: + active: + type: boolean + email: + type: string + mobile: + type: string + role: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Role' + type: object + github_com_mohammad-farrokhnia_library_pkg_response.Meta: + properties: + appName: + example: library + type: string + message: + example: Service is up and running. + type: string + messageCode: + example: HEALTH_OK + type: string + requestId: + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + timestamp: + example: "2024-01-01T00:00:00Z" + type: string + version: + example: "1.0" + type: string + type: object + github_com_mohammad-farrokhnia_library_pkg_response.Response: + properties: + data: {} + meta: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Meta' + type: object + internal_handler.employeeLoginResponse: + properties: + employee: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee' + tokens: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair' + type: object + internal_handler.healthCheck: + properties: + app: + type: string + checks: + additionalProperties: + type: string + type: object + status: + type: string + version: + type: string + type: object +host: localhost:8080 +info: + contact: + email: mohammad.farokhnia.a@gmail.com + name: Mohammad Farrokhnia + url: https://github.com/mohammad-farrokhnia + description: A comprehensive library management system API with full CRUD operations + for books, authors, and members. This API provides a robust foundation for managing + library resources with support for internationalization, detailed error handling, + and comprehensive logging. + license: + name: MIT + url: https://opensource.org/licenses/MIT + title: Library API + version: "1.0" +paths: + /backoffice/auth/forgot-password: + post: + consumes: + - application/json + description: Sends an OTP to the employee's email. Always returns success. + parameters: + - description: Email + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Forgot Password + tags: + - backoffice/auth + /backoffice/auth/login: + post: + consumes: + - application/json + description: Authenticate via email or mobile + password. + parameters: + - description: Login credentials + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.EmployeeLoginInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/internal_handler.employeeLoginResponse' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Employee Login + tags: + - backoffice/auth + /backoffice/auth/logout: + post: + description: Invalidates the current session. + produces: + - application/json + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Employee Logout + tags: + - backoffice/auth + /backoffice/auth/refresh: + post: + consumes: + - application/json + description: Issues a new token pair using a valid refresh token. + parameters: + - description: Refresh token + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair' + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Refresh Access Token + tags: + - backoffice/auth + /backoffice/auth/reset-password: + post: + consumes: + - application/json + description: Verifies OTP and updates the employee's password. Invalidates all + active sessions. + parameters: + - description: OTP and new password + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput' + produces: + - application/json + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Reset Password + tags: + - backoffice/auth + /backoffice/books: + get: + consumes: + - application/json + description: |- + Retrieves a list of all books in the library with pagination, sorting, and filtering. + + **Query Parameters:** + - page: Page number (default: 1) + - size: Items per page (default: 10, max: 100) + - sort: Field to sort by (title, author, genre, publicationYear, createdAt) + - order: Sort order (asc, desc, default: desc) + - search: Search in title, author, or genre + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: size + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: Sort order + in: query + name: order + type: string + - description: Search term + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: Books retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: array + type: object + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: List All Books + tags: + - backoffice/books + post: + consumes: + - application/json + description: |- + Creates a new book in the library. ISBN is optional (for old books without ISBN) but must be unique if provided. + + **Required Fields:** + - title + - author + - language + - publisher + - pages + + **Optional Fields:** + - isbn (must be unique if provided) + - genre + - publicationYear + - description + - totalCopies (defaults to 1 if not provided) + parameters: + - description: Book details + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBookInput' + produces: + - application/json + responses: + "201": + description: Book created successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: object + "400": + description: Invalid request or validation error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: ISBN already exists + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Create Book + tags: + - backoffice/books + /backoffice/books/{id}: + delete: + consumes: + - application/json + description: |- + Permanently deletes a book from the library by its UUID. + + **Business Rules:** + - Book must exist in the system + - Deletion is permanent and cannot be undone + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Book deleted successfully + "404": + description: Book not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Delete Book + tags: + - backoffice/books + get: + consumes: + - application/json + description: Retrieves a specific book by its UUID. + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Book retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: object + "404": + description: Book not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Get Book by ID + tags: + - backoffice/books + put: + consumes: + - application/json + description: |- + Updates specific fields of an existing book. At least one field must be provided. + + **Updatable Fields:** + - genre + - description + - publisher + + **Business Rules:** + - At least one field must be provided + - Book must exist in the system + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + - description: Fields to update + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateBookInput' + produces: + - application/json + responses: + "200": + description: Book updated successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: object + "400": + description: Invalid request or no fields provided + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Book not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Update Book + tags: + - backoffice/books + /backoffice/books/{id}/copies/add: + post: + consumes: + - application/json + description: |- + Adds additional copies of a book to the library inventory. This increases both totalCopies and availableCopies by the specified quantity. + + **Business Rules:** + - Quantity must be at least 1 + - Both total and available copies increase by the same amount + - Book must exist in the system + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + - description: Number of copies to add + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.AddCopiesInput' + produces: + - application/json + responses: + "200": + description: Copies added successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Book not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Add Copies to Book + tags: + - backoffice/books + /backoffice/books/{id}/copies/remove: + post: + consumes: + - application/json + description: |- + Removes copies of a book from the library inventory. This decreases both totalCopies and availableCopies by the specified quantity. + + **Business Rules:** + - Quantity must be at least 1 + - Cannot remove more copies than are currently available + - Both total and available copies decrease by the same amount + - Book must exist in the system + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + - description: Number of copies to remove + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RemoveCopiesInput' + produces: + - application/json + responses: + "200": + description: Copies removed successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Book' + type: object + "400": + description: Invalid request or insufficient copies + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Book not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Remove Copies from Book + tags: + - backoffice/books + /backoffice/books/{id}/recommendations: + get: + description: Returns full book data for recommendations alongside this one. + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BackofficeRecommendation' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Book Recommendations (Backoffice) + tags: + - backoffice/recommendations + /backoffice/borrows: + get: + description: |- + Retrieves all borrow records with pagination, sorting, and search. + + **Query Parameters:** + - page: Page number (default: 1) + - size: Items per page (default: 10, max: 100) + - sort: Field to sort by (borrowedAt, dueDate, status, createdAt) + - order: Sort order (asc, desc) + - search: Search by customer name or book title + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: size + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: Sort order + in: query + name: order + type: string + - description: Search term + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: List All Borrows + tags: + - backoffice/borrows + post: + consumes: + - application/json + description: |- + Creates a new borrow record. + + **Business Rules:** + - Customer must be active + - Book must have available copies + - Customer cannot exceed 3 active borrows + - Customer cannot borrow the same book twice simultaneously + - Default loan period is 14 days + parameters: + - description: Borrow details + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateBorrowInput' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Borrow a Book + tags: + - backoffice/borrows + /backoffice/borrows/{id}/return: + put: + description: Marks a borrow as returned and restores the book's available copies. + parameters: + - description: Borrow UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Borrow' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Return a Book + tags: + - backoffice/borrows + /backoffice/borrows/customers/{id}: + get: + description: Returns all currently active borrows for a specific customer. + parameters: + - description: Customer UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail' + type: array + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: List Customer's Active Borrows + tags: + - backoffice/borrows + /backoffice/customers: + get: + consumes: + - application/json + description: |- + Retrieves a list of all customers in the library with pagination, sorting, and filtering. + + **Query Parameters:** + - page: Page number (default: 1) + - size: Items per page (default: 10, max: 100) + - sort: Field to sort by (firstName, lastName, email, mobile, createdAt) + - order: Sort order (asc, desc, default: desc) + - search: Search in firstName, lastName, email, or mobile + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: size + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: Sort order + in: query + name: order + type: string + - description: Search term + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: Customers retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer' + type: array + type: object + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: List All Customers + tags: + - backoffice/customers + post: + consumes: + - application/json + description: |- + Creates a new customer in the library. A default password will be set automatically. + + **Required Fields:** + - firstName + - lastName + - nationalCode + - email + - birthDate (ISO 8601 date format: YYYY-MM-DD) + + **Optional Fields:** + - mobile (must be unique if provided) + - address + parameters: + - description: Customer details + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateCustomerInput' + produces: + - application/json + responses: + "201": + description: Customer created successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer' + type: object + "400": + description: Invalid request or validation error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Email or mobile already exists + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Create Customer + tags: + - backoffice/customers + /backoffice/customers/{id}: + delete: + consumes: + - application/json + description: |- + Permanently deletes a customer from the library by its UUID. + + **Business Rules:** + - Customer must exist in the system + - Deletion is permanent and cannot be undone + parameters: + - description: Customer UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Customer deleted successfully + "404": + description: Customer not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Delete Customer + tags: + - backoffice/customers + get: + consumes: + - application/json + description: Retrieves a specific customer by its UUID. + parameters: + - description: Customer UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Customer retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer' + type: object + "404": + description: Customer not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Get Customer by ID + tags: + - backoffice/customers + put: + consumes: + - application/json + description: |- + Updates specific fields of an existing customer. Only address and active fields can be updated. + + **Updatable Fields:** + - address + - active + + **Business Rules:** + - Customer must exist in the system + - Other fields (firstName, lastName, email, mobile, nationalCode, birthDate) cannot be updated + parameters: + - description: Customer UUID + in: path + name: id + required: true + type: string + - description: Fields to update + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerInput' + produces: + - application/json + responses: + "200": + description: Customer updated successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Customer' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Customer not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Update Customer + tags: + - backoffice/customers + /backoffice/employees: + get: + consumes: + - application/json + description: |- + Retrieves a list of all employees in the library with pagination, sorting, and filtering. + + **Query Parameters:** + - page: Page number (default: 1) + - size: Items per page (default: 10, max: 100) + - sort: Field to sort by (firstName, lastName, email, role, createdAt) + - order: Sort order (asc, desc, default: desc) + - search: Search in firstName, lastName, or email + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: size + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: Sort order + in: query + name: order + type: string + - description: Search term + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: Employees retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee' + type: array + type: object + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: List All Employees + tags: + - backoffice/employees + post: + consumes: + - application/json + description: |- + Creates a new employee in the library. + + **Required Fields:** + - firstName + - lastName + - email + - mobile + - nationalCode + - birthDate (ISO 8601 date format: YYYY-MM-DD) + - password (min 8 characters) + + **Optional Fields:** + - role (defaults to librarian if not provided) + parameters: + - description: Employee details + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CreateEmployeeInput' + produces: + - application/json + responses: + "201": + description: Employee created successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee' + type: object + "400": + description: Invalid request or validation error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Email, mobile, or national code already exists + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Create Employee + tags: + - backoffice/employees + /backoffice/employees/{id}: + delete: + consumes: + - application/json + description: |- + Permanently deletes an employee from the library by its UUID. + + **Business Rules:** + - Employee must exist in the system + - Deletion is permanent and cannot be undone + parameters: + - description: Employee UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Employee deleted successfully + "404": + description: Employee not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Delete Employee + tags: + - backoffice/employees + get: + consumes: + - application/json + description: Retrieves a specific employee by its UUID. + parameters: + - description: Employee UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Employee retrieved successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee' + type: object + "404": + description: Employee not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Get Employee by ID + tags: + - backoffice/employees + put: + consumes: + - application/json + description: |- + Updates specific fields of an existing employee. + + **Updatable Fields:** + - email + - role + - active + + **Business Rules:** + - Employee must exist in the system + - Email must be unique if updated + parameters: + - description: Employee UUID + in: path + name: id + required: true + type: string + - description: Fields to update + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateEmployeeInput' + produces: + - application/json + responses: + "200": + description: Employee updated successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeEmployee' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "404": + description: Employee not found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Email already exists + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Update Employee + tags: + - backoffice/employees + /backoffice/reports/books/low-availability: + get: + description: Books where available copies are at or below the threshold. + parameters: + - description: 'Available copies threshold (default: 2)' + in: query + name: threshold + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LowAvailabilityBook' + type: array + type: object + security: + - Bearer: [] + summary: Low Availability Books + tags: + - backoffice/reports + /backoffice/reports/books/top: + get: + description: Books ranked by total borrow count. + parameters: + - description: 'Number of results (default: 10, max: 50)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopBook' + type: array + type: object + security: + - Bearer: [] + summary: Top Borrowed Books + tags: + - backoffice/reports + /backoffice/reports/borrows/overdue: + get: + description: All currently overdue borrows, ordered by most overdue first. + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.OverdueBorrow' + type: array + type: object + security: + - Bearer: [] + summary: Overdue Borrows + tags: + - backoffice/reports + /backoffice/reports/borrows/trends: + get: + description: Borrow counts grouped by day, week, or month over a date range. + parameters: + - description: 'daily | weekly | monthly (default: monthly)' + in: query + name: period + type: string + - description: 'Start date YYYY-MM-DD (default: 30 days ago)' + in: query + name: from + type: string + - description: 'End date YYYY-MM-DD (default: today)' + in: query + name: to + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowTrend' + type: array + type: object + security: + - Bearer: [] + summary: Borrow Trends + tags: + - backoffice/reports + /backoffice/reports/customers/top: + get: + description: Customers ranked by total borrow count. + parameters: + - description: 'Number of results (default: 10, max: 50)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TopCustomer' + type: array + type: object + security: + - Bearer: [] + summary: Most Active Customers + tags: + - backoffice/reports + /backoffice/reports/genres/popularity: + get: + description: Genres ranked by total borrow count. + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.GenrePopularity' + type: array + type: object + security: + - Bearer: [] + summary: Genre Popularity + tags: + - backoffice/reports + /backoffice/reports/summary/monthly: + get: + description: Total borrows, returns, overdue, new customers, and new books for + a month. + parameters: + - description: 'Month in YYYY-MM format (default: current month)' + in: query + name: month + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.MonthlySummary' + type: object + security: + - Bearer: [] + summary: Monthly Summary + tags: + - backoffice/reports + /backoffice/search/books: + get: + description: Full-text search returning complete book data including ISBN and + copy counts. + parameters: + - description: Search query + in: query + name: q + type: string + - description: Enable fuzzy matching + in: query + name: fuzzy + type: boolean + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Backoffice Book Search + tags: + - backoffice/search + /health: + get: + description: Returns the live status of the API and all dependencies. + produces: + - application/json + responses: + "200": + description: All systems operational + schema: + $ref: '#/definitions/internal_handler.healthCheck' + "206": + description: Partial — some non-critical services degraded + schema: + $ref: '#/definitions/internal_handler.healthCheck' + "503": + description: Critical dependency unavailable + schema: + $ref: '#/definitions/internal_handler.healthCheck' + summary: Health Check + tags: + - system + /portal/auth/forgot-password: + post: + consumes: + - application/json + description: Sends an OTP to the customer's email. Always returns success for + security. + parameters: + - description: Email address + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ForgotPasswordInput' + produces: + - application/json + responses: + "200": + description: OTP sent successfully + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "400": + description: Invalid email format + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Forgot Password + tags: + - portal/auth + /portal/auth/google: + get: + description: Redirects customer to Google OAuth consent screen. + produces: + - text/html + responses: + "302": + description: Redirect to Google OAuth + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Google OAuth Redirect + tags: + - portal/auth + /portal/auth/google/callback: + get: + description: Handles Google OAuth callback and authenticates customer. + parameters: + - description: Authorization code from Google + in: query + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: Authentication successful + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse' + type: object + "400": + description: Missing authorization code + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "401": + description: Authentication failed + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Google OAuth Callback + tags: + - portal/auth + /portal/auth/login: + post: + consumes: + - application/json + description: Authenticates a customer using email or mobile with password. + parameters: + - description: Login credentials + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerLoginInput' + produces: + - application/json + responses: + "200": + description: Login successful + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.LoginResponse' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "401": + description: Invalid credentials + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Customer Login + tags: + - portal/auth + /portal/auth/logout: + post: + description: Invalidates the current customer session. + produces: + - application/json + responses: + "204": + description: Logout successful + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Customer Logout + tags: + - portal/auth + /portal/auth/refresh: + post: + consumes: + - application/json + description: Issues a new token pair using a valid refresh token. + parameters: + - description: Refresh token + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.RefreshTokenInput' + produces: + - application/json + responses: + "200": + description: Token refreshed successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.TokenPair' + type: object + "400": + description: Invalid request + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "401": + description: Invalid or expired refresh token + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Refresh Customer Token + tags: + - portal/auth + /portal/auth/reset-password: + post: + consumes: + - application/json + description: Verifies OTP and updates the customer's password. Invalidates all + active sessions. + parameters: + - description: OTP and new password + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.ResetPasswordInput' + produces: + - application/json + responses: + "204": + description: Password reset successfully + "400": + description: Invalid request or validation error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "401": + description: Invalid or expired OTP + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Reset Password + tags: + - portal/auth + /portal/auth/signup: + post: + consumes: + - application/json + description: |- + Creates a new customer account with email/mobile and password. + **Required Fields:** + - firstName + - lastName + - email + - mobile + - password + parameters: + - description: Signup details + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.CustomerSignupInput' + produces: + - application/json + responses: + "201": + description: Account created successfully + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer' + type: object + "400": + description: Invalid request or validation error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "409": + description: Email or mobile already exists + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal server error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Customer Signup + tags: + - portal/auth + /portal/books: + get: + description: Paginated list of books with public info. No auth required. + parameters: + - description: Page + in: query + name: page + type: integer + - description: Size + in: query + name: size + type: integer + - description: Sort field + in: query + name: sort + type: string + - description: asc | desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook' + type: array + type: object + summary: Browse Books + tags: + - portal/books + /portal/books/{id}: + get: + description: Returns public book info by ID. No auth required. + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.PublicBook' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Get Book Detail + tags: + - portal/books + /portal/books/{id}/recommendations: + get: + description: Returns books frequently borrowed alongside this one. No auth required. + parameters: + - description: Book UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Book Recommendations (Portal) + tags: + - portal/recommendations + /portal/books/search: + get: + description: Full-text Elasticsearch search returning public book info. + parameters: + - description: Search query + in: query + name: q + type: string + - description: Enable fuzzy + in: query + name: fuzzy + type: boolean + - description: Page + in: query + name: page + type: integer + - description: Size + in: query + name: size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Search Books (Portal) + tags: + - portal/books + /portal/books/suggest: + get: + parameters: + - description: Partial query (min 2 chars) + in: query + name: q + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Book Autocomplete (Portal) + tags: + - portal/books + /portal/me: + get: + description: Returns the authenticated customer's profile. + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer' + type: object + security: + - Bearer: [] + summary: Get My Profile + tags: + - portal/me + put: + consumes: + - application/json + description: Customers can update their birth date and national code. + parameters: + - description: Fields to update + in: body + name: input + required: true + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.UpdateCustomerProfileInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.SafeCustomer' + type: object + security: + - Bearer: [] + summary: Update My Profile + tags: + - portal/me + /portal/me/borrows: + get: + description: |- + Returns all borrows for the authenticated customer with optional status filter. + + **Query Parameters:** + - status: Filter by status (active | returned | overdue). Omit for all. + parameters: + - description: active | returned | overdue + in: query + name: status + type: string + - description: Page + in: query + name: page + type: integer + - description: Size + in: query + name: size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.BorrowDetail' + type: array + type: object + security: + - Bearer: [] + summary: My Borrow History + tags: + - portal/borrows + /portal/me/recommendations: + get: + description: Returns books tailored to the authenticated customer's borrowing + history. + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + - properties: + data: + items: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_internal_domain.Recommendation' + type: array + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + security: + - Bearer: [] + summary: Personal Recommendations + tags: + - portal/recommendations + /search/books: + get: + description: Full-text search with optional fuzzy matching. Returns limited + book info. + parameters: + - description: Search query + in: query + name: q + type: string + - description: Enable fuzzy matching + in: query + name: fuzzy + type: boolean + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Public Book Search + tags: + - search + /search/books/suggest: + get: + description: Returns title and author suggestions for a partial query. + parameters: + - description: Partial search term (min 2 chars) + in: query + name: q + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_mohammad-farrokhnia_library_pkg_response.Response' + summary: Book Autocomplete + tags: + - search +securityDefinitions: + Bearer: + description: Type "Bearer" followed by a space and JWT token. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f5e73aa --- /dev/null +++ b/go.mod @@ -0,0 +1,131 @@ +module github.com/mohammad-farrokhnia/library + +go 1.26 + +require ( + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.5.4 + github.com/jmoiron/sqlx v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 + golang.org/x/crypto v0.51.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.8.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/elastic/elastic-transport-go/v8 v8.9.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/elastic/go-elasticsearch/v8 v8.19.6 + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/cors v1.7.7 + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect + github.com/redis/go-redis/v9 v9.19.0 + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a38cc2b --- /dev/null +++ b/go.sum @@ -0,0 +1,349 @@ +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= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs= +github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc= +github.com/elastic/go-elasticsearch/v8 v8.19.6 h1:4qa7ecJkr5rLsoHKIVGbaqcFt2o57CnOHQJi9Pts/rk= +github.com/elastic/go-elasticsearch/v8 v8.19.6/go.mod h1:jeWebApE1oFEW/hKZqx/IRYmP/aa2+WMJkOfk+AduSI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +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.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +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.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/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-20210616094352-59db8d763f22/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/domain/auth_dom.go b/internal/domain/auth_dom.go new file mode 100644 index 0000000..2bc3fc4 --- /dev/null +++ b/internal/domain/auth_dom.go @@ -0,0 +1,86 @@ +package domain + +import "github.com/golang-jwt/jwt/v5" + +const ( + SubjectTypeEmployee = "employee" + SubjectTypeCustomer = "customer" +) + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type EmployeeClaims struct { + EmployeeID string `json:"employeeId"` + Role Role `json:"role"` + SubjectType string `json:"subjectType"` + jwt.RegisteredClaims +} + +type CustomerClaims struct { + CustomerID string `json:"customerId"` + SubjectType string `json:"subjectType"` + jwt.RegisteredClaims +} + +type CustomerSignupInput struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Mobile string `json:"mobile"` + Password string `json:"password"` +} + +type CustomerLoginInput struct { + Handler string `json:"handler"` + Email string `json:"email"` + Mobile string `json:"mobile"` + Password string `json:"password"` +} + +type ForgotPasswordInput struct { + Email string `json:"email"` +} + +type ResetPasswordInput struct { + Email string `json:"email"` + OTP string `json:"otp"` + NewPassword string `json:"newPassword"` +} + +type RefreshTokenInput struct { + RefreshToken string `json:"refreshToken"` +} + +type LoginEmployeeViaEmailInput struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginEmployeeViaMobileInput struct { + Mobile string `json:"mobile"` + Password string `json:"password"` +} + +type EmployeeLoginInput struct { + Handler string `json:"handler"` + Email string `json:"email"` + Mobile string `json:"mobile"` + Password string `json:"password"` +} + +type Claims struct { + UserID string `json:"userId"` + SubjectType string `json:"subjectType"` + Role *Role `json:"role,omitempty"` + jwt.RegisteredClaims +} + +type LoginResponse struct { + Tokens *TokenPair `json:"tokens"` + Customer SafeCustomer `json:"customer"` +} diff --git a/internal/domain/book_dom.go b/internal/domain/book_dom.go new file mode 100644 index 0000000..473d5e5 --- /dev/null +++ b/internal/domain/book_dom.go @@ -0,0 +1,79 @@ +package domain + +import "time" + +type Book struct { + ID string `db:"id" json:"id"` + Title string `db:"title" json:"title"` + Author string `db:"author" json:"author"` + ISBN *string `db:"isbn" json:"isbn,omitempty"` + Genre string `db:"genre" json:"genre"` + PublicationYear int `db:"publication_year" json:"publicationYear"` + Pages int `db:"pages" json:"pages"` + Language string `db:"language" json:"language"` + Publisher string `db:"publisher" json:"publisher"` + Description string `db:"description" json:"description"` + TotalCopies int `db:"total_copies" json:"totalCopies"` + AvailableCopies int `db:"available_copies" json:"availableCopies"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` +} + +type PublicBook struct { + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + Genre string `json:"genre"` + Description string `json:"description"` + Language string `json:"language"` + Publisher string `json:"publisher"` + PublicationYear int `json:"publicationYear"` + Pages int `json:"pages"` + IsAvailable bool `json:"isAvailable"` +} + +type CreateBookInput struct { + Title string `json:"title"` + Author string `json:"author"` + ISBN *string `json:"isbn,omitempty"` + Genre string `json:"genre"` + PublicationYear int `json:"publicationYear"` + Language string `json:"language"` + Publisher string `json:"publisher"` + Pages int `json:"pages"` + Description string `json:"description"` + TotalCopies int `json:"totalCopies"` +} + +type UpdateBookInput struct { + Genre *string `json:"genre"` + Description *string `json:"description"` + Publisher *string `json:"publisher"` +} + +type AddCopiesInput struct { + Quantity int `json:"quantity" binding:"required,min=1"` +} + +type RemoveCopiesInput struct { + Quantity int `json:"quantity" binding:"required,min=1"` +} + +func (b *Book) IsAvailable() bool { + return b.AvailableCopies > 0 +} + +func (b *Book) Public() PublicBook { + return PublicBook{ + ID: b.ID, + Title: b.Title, + Author: b.Author, + Genre: b.Genre, + Description: b.Description, + Language: b.Language, + Publisher: b.Publisher, + PublicationYear: b.PublicationYear, + Pages: b.Pages, + IsAvailable: b.IsAvailable(), + } +} diff --git a/internal/domain/book_dom_test.go b/internal/domain/book_dom_test.go new file mode 100644 index 0000000..da587e4 --- /dev/null +++ b/internal/domain/book_dom_test.go @@ -0,0 +1,100 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestBook_IsAvailable(t *testing.T) { + tests := []struct { + name string + availableCopies int + expected bool + }{ + { + name: "available when copies > 0", + availableCopies: 5, + expected: true, + }, + { + name: "available when copies = 1", + availableCopies: 1, + expected: true, + }, + { + name: "not available when copies = 0", + availableCopies: 0, + expected: false, + }, + { + name: "not available when copies < 0", + availableCopies: -1, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + book := &domain.Book{ + AvailableCopies: tt.availableCopies, + } + result := book.IsAvailable() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBook_Public(t *testing.T) { + now := time.Now() + book := &domain.Book{ + ID: "book-123", + Title: "1984", + Author: "George Orwell", + Genre: "Fiction", + Description: "A dystopian novel", + Language: "English", + Publisher: "Secker", + PublicationYear: 1949, + Pages: 328, + TotalCopies: 5, + AvailableCopies: 3, + CreatedAt: now, + UpdatedAt: now, + } + + public := book.Public() + + assert.Equal(t, book.ID, public.ID) + assert.Equal(t, book.Title, public.Title) + assert.Equal(t, book.Author, public.Author) + assert.Equal(t, book.Genre, public.Genre) + assert.Equal(t, book.Description, public.Description) + assert.Equal(t, book.Language, public.Language) + assert.Equal(t, book.Publisher, public.Publisher) + assert.Equal(t, book.PublicationYear, public.PublicationYear) + assert.Equal(t, book.Pages, public.Pages) + assert.Equal(t, true, public.IsAvailable) +} + +func TestBook_Public_NotAvailable(t *testing.T) { + book := &domain.Book{ + ID: "book-456", + Title: "No Copies", + Author: "Author", + Genre: "Genre", + Description: "Description", + Language: "English", + Publisher: "Publisher", + PublicationYear: 2020, + Pages: 100, + TotalCopies: 5, + AvailableCopies: 0, + } + + public := book.Public() + + assert.Equal(t, false, public.IsAvailable) +} diff --git a/internal/domain/borrow_dom.go b/internal/domain/borrow_dom.go new file mode 100644 index 0000000..323fe98 --- /dev/null +++ b/internal/domain/borrow_dom.go @@ -0,0 +1,48 @@ +package domain + +import "time" + +type BorrowStatus string + +const ( + BorrowStatusActive BorrowStatus = "active" + BorrowStatusReturned BorrowStatus = "returned" + BorrowStatusOverdue BorrowStatus = "overdue" +) + +const DefaultBorrowDays = 14 + +type Borrow struct { + ID string `db:"id" json:"id"` + CustomerID string `db:"customer_id" json:"customerId"` + BookID string `db:"book_id" json:"bookId"` + BorrowedAt time.Time `db:"borrowed_at" json:"borrowedAt"` + DueDate time.Time `db:"due_date" json:"dueDate"` + ReturnedAt *time.Time `db:"returned_at" json:"returnedAt"` + Status BorrowStatus `db:"status" json:"status"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` +} + +func (b *Borrow) IsOverdue() bool { + return b.ReturnedAt == nil && time.Now().After(b.DueDate) +} + +func (b *Borrow) DaysUntilDue() int { + if b.ReturnedAt != nil { + return 0 + } + return int(time.Until(b.DueDate).Hours() / 24) +} + +type BorrowDetail struct { + Borrow + CustomerName string `db:"customer_name" json:"customerName"` + BookTitle string `db:"book_title" json:"bookTitle"` + BookISBN *string `db:"book_isbn" json:"bookIsbn"` +} + +type CreateBorrowInput struct { + CustomerID string `json:"customerId"` + BookID string `json:"bookId"` +} diff --git a/internal/domain/borrow_dom_test.go b/internal/domain/borrow_dom_test.go new file mode 100644 index 0000000..329263a --- /dev/null +++ b/internal/domain/borrow_dom_test.go @@ -0,0 +1,102 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +func TestBorrow_IsOverdue(t *testing.T) { + tests := []struct { + name string + borrow *domain.Borrow + expected bool + }{ + { + name: "not overdue - future due date", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(24 * time.Hour), + ReturnedAt: nil, + }, + expected: false, + }, + { + name: "overdue - past due date", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(-24 * time.Hour), + ReturnedAt: nil, + }, + expected: true, + }, + { + name: "returned - not overdue", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(-24 * time.Hour), + ReturnedAt: timePtr(time.Now()), + }, + expected: false, + }, + { + name: "returned before due - not overdue", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(24 * time.Hour), + ReturnedAt: timePtr(time.Now()), + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.borrow.IsOverdue() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBorrow_DaysUntilDue(t *testing.T) { + tests := []struct { + name string + borrow *domain.Borrow + expected int + }{ + { + name: "returned - 0 days", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(24 * time.Hour), + ReturnedAt: timePtr(time.Now()), + }, + expected: 0, + }, + { + name: "due in 3 days", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(72 * time.Hour), + ReturnedAt: nil, + }, + expected: 2, + }, + { + name: "overdue - negative days", + borrow: &domain.Borrow{ + DueDate: time.Now().Add(-24 * time.Hour), + ReturnedAt: nil, + }, + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.borrow.DaysUntilDue() + assert.Equal(t, tt.expected, result) + }) + } +} + +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/internal/domain/customer_dom.go b/internal/domain/customer_dom.go new file mode 100644 index 0000000..670ea6d --- /dev/null +++ b/internal/domain/customer_dom.go @@ -0,0 +1,83 @@ +package domain + +import "time" + +type Customer struct { + ID string `db:"id" json:"id"` + GoogleID *string `db:"google_id" json:"-"` + AuthProvider string `db:"auth_provider" json:"authProvider"` + + FirstName string `db:"first_name" json:"firstName"` + LastName string `db:"last_name" json:"lastName"` + Email *string `db:"email" json:"-"` + EmailShowcase *string `db:"email_showcase" json:"email"` + NationalCode *string `db:"national_code" json:"nationalCode"` + Mobile *string `db:"mobile" json:"mobile"` + Password string `db:"password" json:"password"` + BirthDate *time.Time `db:"birth_date" json:"birthDate"` + Address *string `db:"address" json:"address"` + Active bool `db:"active" json:"active"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` +} + +func (c *Customer) FullName() string { + return c.FirstName + " " + c.LastName +} + +// TODO: update birthDate to Date instead od Time.time. +type CreateCustomerInput struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email *string `json:"email"` + EmailShowcase *string `json:"-"` + Mobile *string `json:"mobile"` + Address *string `json:"address"` + NationalCode *string `json:"nationalCode"` + BirthDate *time.Time `json:"birthDate"` + GoogleID *string `json:"googleId"` + AuthProvider string `json:"authProvider"` + Password *string `json:"password"` +} + +type UpdateCustomerInput struct { + Address *string `json:"address"` + Active *bool `json:"active"` +} + +type UpdateCustomerProfileInput struct { + BirthDate *time.Time `json:"birthDate"` + NationalCode *string `json:"nationalCode"` +} + +type SafeCustomer struct { + ID string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email *string `json:"email"` + NationalCode *string `json:"nationalCode"` + Mobile *string `json:"mobile"` + BirthDate *time.Time `json:"birthDate"` + Address *string `json:"address"` + Active bool `json:"active"` + AuthProvider string `json:"authProvider"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (c *Customer) Safe() SafeCustomer { + return SafeCustomer{ + ID: c.ID, + FirstName: c.FirstName, + LastName: c.LastName, + Email: c.EmailShowcase, + NationalCode: c.NationalCode, + Mobile: c.Mobile, + BirthDate: c.BirthDate, + Address: c.Address, + Active: c.Active, + AuthProvider: c.AuthProvider, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} diff --git a/internal/domain/customer_dom_test.go b/internal/domain/customer_dom_test.go new file mode 100644 index 0000000..98f4c2c --- /dev/null +++ b/internal/domain/customer_dom_test.go @@ -0,0 +1,79 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +func TestCustomer_FullName(t *testing.T) { + customer := &domain.Customer{ + FirstName: "John", + LastName: "Doe", + } + + result := customer.FullName() + assert.Equal(t, "John Doe", result) +} + +func TestCustomer_Safe(t *testing.T) { + now := time.Now() + emailShowcase := "j***@example.com" + address := "123 Main St" + email := "john@example.com" + nationalCode := "1234567890" + mobile := "09123456789" + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + Email: &email, + EmailShowcase: &emailShowcase, + NationalCode: &nationalCode, + Mobile: &mobile, + BirthDate: timePtr(now), + Address: &address, + Active: true, + AuthProvider: "local", + CreatedAt: now, + UpdatedAt: now, + } + + safe := customer.Safe() + + assert.Equal(t, customer.ID, safe.ID) + assert.Equal(t, customer.FirstName, safe.FirstName) + assert.Equal(t, customer.LastName, safe.LastName) + assert.Equal(t, customer.EmailShowcase, safe.Email) + assert.Equal(t, customer.NationalCode, safe.NationalCode) + assert.Equal(t, customer.Mobile, safe.Mobile) + assert.Equal(t, customer.BirthDate, safe.BirthDate) + assert.Equal(t, customer.Address, safe.Address) + assert.Equal(t, customer.Active, safe.Active) + assert.Equal(t, customer.AuthProvider, safe.AuthProvider) + assert.Equal(t, customer.CreatedAt, safe.CreatedAt) + assert.Equal(t, customer.UpdatedAt, safe.UpdatedAt) +} + +func TestCustomer_Safe_NilAddress(t *testing.T) { + emailShowcase := "j***@example.com" + nationalCode := "1234567890" + mobile := "09123456789" + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + EmailShowcase: &emailShowcase, + NationalCode: &nationalCode, + Mobile: &mobile, + Address: nil, + Active: true, + AuthProvider: "local", + } + + safe := customer.Safe() + assert.Nil(t, safe.Address) +} diff --git a/internal/domain/employee_dom.go b/internal/domain/employee_dom.go new file mode 100644 index 0000000..b7bea4c --- /dev/null +++ b/internal/domain/employee_dom.go @@ -0,0 +1,85 @@ +package domain + +import "time" + +type Role string + +const ( + RoleLibrarian Role = "librarian" + RoleManager Role = "manager" + RoleSuperAdmin Role = "super_admin" +) + +func (r Role) IsValid() bool { + return r == RoleLibrarian || r == RoleManager || r == RoleSuperAdmin +} + +func (r Role) AtLeast(minimum Role) bool { + levels := map[Role]int{ + RoleLibrarian: 1, + RoleManager: 2, + RoleSuperAdmin: 3, + } + return levels[r] >= levels[minimum] +} + +type Employee struct { + ID string `db:"id" json:"id"` + FirstName string `db:"first_name" json:"firstName"` + LastName string `db:"last_name" json:"lastName"` + Email string `db:"email" json:"email"` + EmailShowcase string `db:"email_showcase" json:"-"` + Mobile string `db:"mobile" json:"mobile"` + NationalCode string `db:"national_code" json:"nationalCode"` + BirthDate *time.Time `db:"birth_date" json:"birthDate"` + Password string `db:"password" json:"-"` + Role Role `db:"role" json:"role"` + Active bool `db:"active" json:"active"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` +} + +type SafeEmployee struct { + ID string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Mobile string `json:"mobile"` + Role Role `json:"role"` + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (e *Employee) Safe() SafeEmployee { + return SafeEmployee{ + ID: e.ID, + FirstName: e.FirstName, + LastName: e.LastName, + Email: e.Email, + Mobile: e.Mobile, + Role: e.Role, + Active: e.Active, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +type CreateEmployeeInput struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + EmailShowcase string `json:"emailShowcase"` + Mobile string `json:"mobile"` + NationalCode string `json:"nationalCode"` + BirthDate *time.Time `json:"birthDate"` + Password string `json:"password"` + Role Role `json:"role"` +} + +type UpdateEmployeeInput struct { + Email *string `json:"email"` + Mobile *string `json:"mobile"` + Role *Role `json:"role"` + Active *bool `json:"active"` +} diff --git a/internal/domain/employee_dom_test.go b/internal/domain/employee_dom_test.go new file mode 100644 index 0000000..2bf4b25 --- /dev/null +++ b/internal/domain/employee_dom_test.go @@ -0,0 +1,82 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +func TestRole_IsValid(t *testing.T) { + tests := []struct { + name string + role domain.Role + expected bool + }{ + {"librarian valid", domain.RoleLibrarian, true}, + {"manager valid", domain.RoleManager, true}, + {"super_admin valid", domain.RoleSuperAdmin, true}, + {"invalid role", domain.Role("invalid"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.role.IsValid() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRole_AtLeast(t *testing.T) { + tests := []struct { + name string + role domain.Role + min domain.Role + expected bool + }{ + {"librarian at least librarian", domain.RoleLibrarian, domain.RoleLibrarian, true}, + {"librarian at least manager", domain.RoleLibrarian, domain.RoleManager, false}, + {"manager at least librarian", domain.RoleManager, domain.RoleLibrarian, true}, + {"manager at least manager", domain.RoleManager, domain.RoleManager, true}, + {"manager at least super_admin", domain.RoleManager, domain.RoleSuperAdmin, false}, + {"super_admin at least librarian", domain.RoleSuperAdmin, domain.RoleLibrarian, true}, + {"super_admin at least manager", domain.RoleSuperAdmin, domain.RoleManager, true}, + {"super_admin at least super_admin", domain.RoleSuperAdmin, domain.RoleSuperAdmin, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.role.AtLeast(tt.min) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEmployee_Safe(t *testing.T) { + now := time.Now() + employee := &domain.Employee{ + ID: "emp-1", + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + Role: domain.RoleManager, + Active: true, + CreatedAt: now, + UpdatedAt: now, + } + + safe := employee.Safe() + + assert.Equal(t, employee.ID, safe.ID) + assert.Equal(t, employee.FirstName, safe.FirstName) + assert.Equal(t, employee.LastName, safe.LastName) + assert.Equal(t, employee.Email, safe.Email) + assert.Equal(t, employee.Mobile, safe.Mobile) + assert.Equal(t, employee.Role, safe.Role) + assert.Equal(t, employee.Active, safe.Active) + assert.Equal(t, employee.CreatedAt, safe.CreatedAt) + assert.Equal(t, employee.UpdatedAt, safe.UpdatedAt) +} diff --git a/internal/domain/query.go b/internal/domain/query.go new file mode 100644 index 0000000..f95b383 --- /dev/null +++ b/internal/domain/query.go @@ -0,0 +1,60 @@ +package domain + +import "strings" + +type Pagination struct { + Page int `json:"page" form:"page"` + Size int `json:"size" form:"size"` +} + +func (p *Pagination) Validate() { + if p.Page < 1 { + p.Page = 1 + } + if p.Size < 1 { + p.Size = 10 + } + if p.Size > 100 { + p.Size = 100 + } +} + +func (p *Pagination) Offset() int { + return (p.Page - 1) * p.Size +} + +type Sort struct { + Field string `json:"field" form:"sort"` + Order string `json:"order" form:"order"` +} + +func (s *Sort) Validate(allowedFields map[string]string) { + if s.Field == "" { + s.Field = "created_at" + } + if s.Order == "" { + s.Order = "desc" + } + s.Order = normalizeOrder(s.Order) + if dbField, ok := allowedFields[s.Field]; ok { + s.Field = dbField + } +} + +func normalizeOrder(order string) string { + order = strings.ToLower(order) + if order == "asc" || order == "desc" { + return order + } + return "desc" +} + +type Filter struct { + Search string `json:"search" form:"search"` +} + +type QueryParams struct { + Pagination Pagination + Sort Sort + Filter Filter +} diff --git a/internal/domain/query_test.go b/internal/domain/query_test.go new file mode 100644 index 0000000..eaa29c8 --- /dev/null +++ b/internal/domain/query_test.go @@ -0,0 +1,109 @@ +package domain_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +func TestPagination_Validate(t *testing.T) { + tests := []struct { + name string + page int + size int + expectedPage int + expectedSize int + }{ + {"valid values", 2, 20, 2, 20}, + {"zero page defaults to 1", 0, 20, 1, 20}, + {"negative page defaults to 1", -1, 20, 1, 20}, + {"zero size defaults to 10", 1, 0, 1, 10}, + {"negative size defaults to 10", 1, -1, 1, 10}, + {"size > 100 caps to 100", 1, 150, 1, 100}, + {"size = 100 stays 100", 1, 100, 1, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &domain.Pagination{Page: tt.page, Size: tt.size} + p.Validate() + assert.Equal(t, tt.expectedPage, p.Page) + assert.Equal(t, tt.expectedSize, p.Size) + }) + } +} + +func TestPagination_Offset(t *testing.T) { + tests := []struct { + name string + page int + size int + expectedOffset int + }{ + {"page 1 size 10", 1, 10, 0}, + {"page 2 size 10", 2, 10, 10}, + {"page 3 size 20", 3, 20, 40}, + {"page 1 size 50", 1, 50, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &domain.Pagination{Page: tt.page, Size: tt.size} + result := p.Offset() + assert.Equal(t, tt.expectedOffset, result) + }) + } +} + +func TestSort_Validate(t *testing.T) { + tests := []struct { + name string + field string + order string + expectedField string + expectedOrder string + }{ + {"empty defaults", "", "", "created_at", "desc"}, + {"valid field and order", "title", "asc", "title", "asc"}, + {"valid field desc", "title", "desc", "title", "desc"}, + {"invalid order defaults to desc", "title", "invalid", "title", "desc"}, + {"uppercase order normalized", "title", "ASC", "title", "asc"}, + {"mixed case order normalized", "title", "DeSc", "title", "desc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &domain.Sort{Field: tt.field, Order: tt.order} + allowedFields := map[string]string{ + "title": "title", + "created_at": "created_at", + } + s.Validate(allowedFields) + assert.Equal(t, tt.expectedField, s.Field) + assert.Equal(t, tt.expectedOrder, s.Order) + }) + } +} + +func TestSort_Validate_WithFieldMapping(t *testing.T) { + s := &domain.Sort{Field: "title", Order: "asc"} + allowedFields := map[string]string{ + "title": "books.title", + "author": "books.author", + } + s.Validate(allowedFields) + assert.Equal(t, "books.title", s.Field) + assert.Equal(t, "asc", s.Order) +} + +func TestSort_Validate_InvalidField(t *testing.T) { + s := &domain.Sort{Field: "invalid", Order: "asc"} + allowedFields := map[string]string{ + "title": "books.title", + } + s.Validate(allowedFields) + assert.Equal(t, "invalid", s.Field) + assert.Equal(t, "asc", s.Order) +} diff --git a/internal/domain/recommendation_dom.go b/internal/domain/recommendation_dom.go new file mode 100644 index 0000000..43b248f --- /dev/null +++ b/internal/domain/recommendation_dom.go @@ -0,0 +1,30 @@ +package domain + +type BookWithScore struct { + Book + Score int `db:"recommendation_score"` +} + +type Recommendation struct { + Book PublicBook `json:"book"` + Reason string `json:"reason"` +} + +type BackofficeRecommendation struct { + Book Book `json:"book"` + Score int `json:"score"` +} + +func (b *BookWithScore) ToRecommendation(reason string) Recommendation { + return Recommendation{ + Book: b.Public(), + Reason: reason, + } +} + +func (b *BookWithScore) ToBackofficeRecommendation() BackofficeRecommendation { + return BackofficeRecommendation{ + Book: b.Book, + Score: b.Score, + } +} diff --git a/internal/domain/recommendation_dom_test.go b/internal/domain/recommendation_dom_test.go new file mode 100644 index 0000000..8861214 --- /dev/null +++ b/internal/domain/recommendation_dom_test.go @@ -0,0 +1,88 @@ +package domain_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +func TestBookWithScore_ToRecommendation(t *testing.T) { + now := time.Now() + book := &domain.Book{ + ID: "book-1", + Title: "1984", + Author: "George Orwell", + Genre: "Fiction", + Description: "A dystopian novel", + Language: "English", + Publisher: "Secker", + PublicationYear: 1949, + Pages: 328, + TotalCopies: 5, + AvailableCopies: 3, + CreatedAt: now, + UpdatedAt: now, + } + + bookWithScore := &domain.BookWithScore{ + Book: *book, + Score: 95, + } + + reason := "Customers who borrowed this book also borrowed" + result := bookWithScore.ToRecommendation(reason) + + assert.Equal(t, book.ID, result.Book.ID) + assert.Equal(t, book.Title, result.Book.Title) + assert.Equal(t, book.Author, result.Book.Author) + assert.Equal(t, book.Genre, result.Book.Genre) + assert.Equal(t, book.Description, result.Book.Description) + assert.Equal(t, book.Language, result.Book.Language) + assert.Equal(t, book.Publisher, result.Book.Publisher) + assert.Equal(t, book.PublicationYear, result.Book.PublicationYear) + assert.Equal(t, book.Pages, result.Book.Pages) + assert.Equal(t, true, result.Book.IsAvailable) + assert.Equal(t, reason, result.Reason) +} + +func TestBookWithScore_ToBackofficeRecommendation(t *testing.T) { + now := time.Now() + book := &domain.Book{ + ID: "book-1", + Title: "1984", + Author: "George Orwell", + Genre: "Fiction", + Description: "A dystopian novel", + Language: "English", + Publisher: "Secker", + PublicationYear: 1949, + Pages: 328, + TotalCopies: 5, + AvailableCopies: 3, + CreatedAt: now, + UpdatedAt: now, + } + + bookWithScore := &domain.BookWithScore{ + Book: *book, + Score: 95, + } + + result := bookWithScore.ToBackofficeRecommendation() + + assert.Equal(t, book.ID, result.Book.ID) + assert.Equal(t, book.Title, result.Book.Title) + assert.Equal(t, book.Author, result.Book.Author) + assert.Equal(t, book.Genre, result.Book.Genre) + assert.Equal(t, book.Description, result.Book.Description) + assert.Equal(t, book.Language, result.Book.Language) + assert.Equal(t, book.Publisher, result.Book.Publisher) + assert.Equal(t, book.PublicationYear, result.Book.PublicationYear) + assert.Equal(t, book.Pages, result.Book.Pages) + assert.Equal(t, book.TotalCopies, result.Book.TotalCopies) + assert.Equal(t, book.AvailableCopies, result.Book.AvailableCopies) + assert.Equal(t, 95, result.Score) +} diff --git a/internal/domain/report_dom.go b/internal/domain/report_dom.go new file mode 100644 index 0000000..b7d2237 --- /dev/null +++ b/internal/domain/report_dom.go @@ -0,0 +1,53 @@ +package domain + +import "time" + +type BorrowTrend struct { + Period string `db:"period" json:"period"` + Count int `db:"count" json:"count"` +} + +type TopBook struct { + Book + BorrowCount int `db:"borrow_count" json:"borrowCount"` +} + +type OverdueBorrow struct { + BorrowDetail + DaysOverdue int `db:"days_overdue" json:"daysOverdue"` +} + +type TopCustomer struct { + CustomerID string `db:"customer_id" json:"customerId"` + CustomerName string `db:"customer_name" json:"customerName"` + Email string `db:"email" json:"email"` + BorrowCount int `db:"borrow_count" json:"borrowCount"` +} + +type GenrePopularity struct { + Genre string `db:"genre" json:"genre"` + BorrowCount int `db:"borrow_count" json:"borrowCount"` +} + +type LowAvailabilityBook struct { + Book + AvailablePercent float64 `db:"available_percent" json:"availablePercent"` +} + +type MonthlySummary struct { + Month string `json:"month"` + TotalBorrows int `db:"total_borrows" json:"totalBorrows"` + TotalReturns int `db:"total_returns" json:"totalReturns"` + OverdueBorrows int `db:"overdue_borrows" json:"overdueBorrows"` + NewCustomers int `db:"new_customers" json:"newCustomers"` + NewBooks int `db:"new_books" json:"newBooks"` +} + +type ReportFilter struct { + From time.Time + To time.Time + Period string + Limit int + Month string + Threshold int +} diff --git a/internal/domain/report_dom_test.go b/internal/domain/report_dom_test.go new file mode 100644 index 0000000..8d0d2e7 --- /dev/null +++ b/internal/domain/report_dom_test.go @@ -0,0 +1 @@ +package domain_test diff --git a/internal/handler/book_handler.go b/internal/handler/book_handler.go new file mode 100644 index 0000000..619e062 --- /dev/null +++ b/internal/handler/book_handler.go @@ -0,0 +1,335 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type BookHandler struct { + svc *service.BookService +} + +func NewBookHandler(svc *service.BookService) *BookHandler { + return &BookHandler{svc: svc} +} + +// List godoc +// @Summary List All Books +// @Description Retrieves a list of all books in the library with pagination, sorting, and filtering. +// @Description +// @Description **Query Parameters:** +// @Description - page: Page number (default: 1) +// @Description - size: Items per page (default: 10, max: 100) +// @Description - sort: Field to sort by (title, author, genre, publicationYear, createdAt) +// @Description - order: Sort order (asc, desc, default: desc) +// @Description - search: Search in title, author, or genre +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param size query int false "Items per page" +// @Param sort query string false "Sort field" +// @Param order query string false "Sort order" +// @Param search query string false "Search term" +// @Success 200 {object} response.Response{data=[]domain.Book} "Books retrieved successfully" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books [get] +// @Security Bearer +func (h *BookHandler) List(c *gin.Context) { + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + //TODO: add validation for params return error if invalid + books, total, err := h.svc.GetAll(c.Request.Context(), params) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OKWithPagination(c, books, i18n.MsgBooksRetrieved, total, params.Pagination.Page, params.Pagination.Size) +} + +// GetByID godoc +// @Summary Get Book by ID +// @Description Retrieves a specific book by its UUID. +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param id path string true "Book UUID" +// @Success 200 {object} response.Response{data=domain.Book} "Book retrieved successfully" +// @Failure 404 {object} response.Response "Book not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books/{id} [get] +// @Security Bearer +func (h *BookHandler) GetByID(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + book, err := h.svc.GetByID(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, book, i18n.MsgBookFound) +} + +// Create godoc +// @Summary Create Book +// @Description Creates a new book in the library. ISBN is optional (for old books without ISBN) but must be unique if provided. +// @Description +// @Description **Required Fields:** +// @Description - title +// @Description - author +// @Description - language +// @Description - publisher +// @Description - pages +// @Description +// @Description **Optional Fields:** +// @Description - isbn (must be unique if provided) +// @Description - genre +// @Description - publicationYear +// @Description - description +// @Description - totalCopies (defaults to 1 if not provided) +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param input body domain.CreateBookInput true "Book details" +// @Success 201 {object} response.Response{data=domain.Book} "Book created successfully" +// @Failure 400 {object} response.Response "Invalid request or validation error" +// @Failure 409 {object} response.Response "ISBN already exists" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books [post] +// @Security Bearer +func (h *BookHandler) Create(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + var input domain.CreateBookInput + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + log.Printf("[book] create decode error: %v", err) + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + v := validator.New(). + Required("title", input.Title). + Required("author", input.Author). + Required("language", input.Language). + Required("publisher", input.Publisher). + Min("pages", input.Pages, 1) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + if input.TotalCopies == 0 { + input.TotalCopies = 1 + } + + book, err := h.svc.Create(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.Created(c, book, i18n.MsgBookCreated) +} + +// Update godoc +// @Summary Update Book +// @Description Updates specific fields of an existing book. At least one field must be provided. +// @Description +// @Description **Updatable Fields:** +// @Description - genre +// @Description - description +// @Description - publisher +// @Description +// @Description **Business Rules:** +// @Description - At least one field must be provided +// @Description - Book must exist in the system +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param id path string true "Book UUID" +// @Param input body domain.UpdateBookInput true "Fields to update" +// @Success 200 {object} response.Response{data=domain.Book} "Book updated successfully" +// @Failure 400 {object} response.Response "Invalid request or no fields provided" +// @Failure 404 {object} response.Response "Book not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books/{id} [put] +// @Security Bearer +func (h *BookHandler) Update(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + var input domain.UpdateBookInput + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + if input.Genre == nil && input.Description == nil && input.Publisher == nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + book, err := h.svc.Update(c.Request.Context(), id, input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, book, i18n.MsgBookUpdated) +} + +// AddCopies godoc +// @Summary Add Copies to Book +// @Description Adds additional copies of a book to the library inventory. This increases both totalCopies and availableCopies by the specified quantity. +// @Description +// @Description **Business Rules:** +// @Description - Quantity must be at least 1 +// @Description - Both total and available copies increase by the same amount +// @Description - Book must exist in the system +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param id path string true "Book UUID" +// @Param input body domain.AddCopiesInput true "Number of copies to add" +// @Success 200 {object} response.Response{data=domain.Book} "Copies added successfully" +// @Failure 400 {object} response.Response "Invalid request" +// @Failure 404 {object} response.Response "Book not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books/{id}/copies/add [post] +// @Security Bearer +func (h *BookHandler) AddCopies(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + var input domain.AddCopiesInput + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + book, err := h.svc.AddCopies(c.Request.Context(), id, input.Quantity) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, book, i18n.MsgBookUpdated) +} + +// RemoveCopies godoc +// @Summary Remove Copies from Book +// @Description Removes copies of a book from the library inventory. This decreases both totalCopies and availableCopies by the specified quantity. +// @Description +// @Description **Business Rules:** +// @Description - Quantity must be at least 1 +// @Description - Cannot remove more copies than are currently available +// @Description - Both total and available copies decrease by the same amount +// @Description - Book must exist in the system +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param id path string true "Book UUID" +// @Param input body domain.RemoveCopiesInput true "Number of copies to remove" +// @Success 200 {object} response.Response{data=domain.Book} "Copies removed successfully" +// @Failure 400 {object} response.Response "Invalid request or insufficient copies" +// @Failure 404 {object} response.Response "Book not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books/{id}/copies/remove [post] +// @Security Bearer +func (h *BookHandler) RemoveCopies(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + var input domain.RemoveCopiesInput + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + book, err := h.svc.RemoveCopies(c.Request.Context(), id, input.Quantity) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, book, i18n.MsgBookUpdated) +} + +// Delete godoc +// @Summary Delete Book +// @Description Permanently deletes a book from the library by its UUID. +// @Description +// @Description **Business Rules:** +// @Description - Book must exist in the system +// @Description - Deletion is permanent and cannot be undone +// @Tags backoffice/books +// @Accept json +// @Produce json +// @Param id path string true "Book UUID" +// @Success 204 "Book deleted successfully" +// @Failure 404 {object} response.Response "Book not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/books/{id} [delete] +// @Security Bearer +func (h *BookHandler) Delete(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + err := h.svc.Delete(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/handler/book_handler_test.go b/internal/handler/book_handler_test.go new file mode 100644 index 0000000..927c612 --- /dev/null +++ b/internal/handler/book_handler_test.go @@ -0,0 +1,153 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +const ( + testBookID = "00000000-0000-0000-0000-000000000001" + testBookIDNew = "00000000-0000-0000-0000-000000000002" +) + +func setupBookRouter(repo *mocks.MockBookRepository) *gin.Engine { + svc := service.NewBookService(repo, nil) + h := handler.NewBookHandler(svc) + + r := helpers.NewRouter() + r.GET("/books", h.List) + r.GET("/books/:id", h.GetByID) + r.POST("/books", h.Create) + r.PUT("/books/:id", h.Update) + r.DELETE("/books/:id", h.Delete) + return r +} + +func TestBookHandler_GetByID_Success(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetByID", anyCtx, testBookID).Return(&domain.Book{ + ID: testBookID, + Title: "1984", + }, nil) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/books/"+testBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + assert.Equal(t, testBookID, data["id"]) + assert.Equal(t, "1984", data["title"]) +} + +func TestBookHandler_GetByID_NotFound(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetByID", anyCtx, testBookID). + Return(nil, customError.NewNotFound(customError.ErrBookNotFound, "book not found")) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/books/"+testBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestBookHandler_GetByID_InvalidUUID(t *testing.T) { + repo := new(mocks.MockBookRepository) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/books/not-a-uuid", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + repo.AssertNotCalled(t, "GetByID") +} + +func TestBookHandler_Create_ValidationFails_MissingTitle(t *testing.T) { + repo := new(mocks.MockBookRepository) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodPost, "/books", map[string]any{ + "author": "George Orwell", + "language": "English", + "publisher": "Secker", + "pages": 328, + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + repo.AssertNotCalled(t, "Create") +} + +func TestBookHandler_Create_Success(t *testing.T) { + repo := new(mocks.MockBookRepository) + input := domain.CreateBookInput{ + Title: "1984", + Author: "George Orwell", + Language: "English", + Publisher: "Secker & Warburg", + Pages: 328, + TotalCopies: 1, + } + repo.On("Create", anyCtx, input).Return(&domain.Book{ + ID: testBookIDNew, + Title: "1984", + }, nil) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodPost, "/books", map[string]any{ + "title": "1984", + "author": "George Orwell", + "language": "English", + "publisher": "Secker & Warburg", + "pages": 328, + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusCreated, w.Code) + repo.AssertExpectations(t) +} + +func TestBookHandler_Create_ISBNConflict(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("Create", anyCtx, anyInput). + Return(nil, customError.NewConflict(customError.ErrBookISBNExists, "ISBN already exists")) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodPost, "/books", map[string]any{ + "title": "1984", "author": "Orwell", + "language": "English", "publisher": "Secker", "pages": 328, + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestBookHandler_Delete_Success(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("Delete", anyCtx, testBookID).Return(nil) + + r := setupBookRouter(repo) + req := helpers.NewRequest(t, http.MethodDelete, "/books/"+testBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + repo.AssertExpectations(t) +} + +var anyCtx = mock.MatchedBy(func(any) bool { return true }) +var anyInput = mock.MatchedBy(func(any) bool { return true }) diff --git a/internal/handler/borrow_handler.go b/internal/handler/borrow_handler.go new file mode 100644 index 0000000..5d86dab --- /dev/null +++ b/internal/handler/borrow_handler.go @@ -0,0 +1,149 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type BorrowHandler struct { + svc *service.BorrowService +} + +func NewBorrowHandler(svc *service.BorrowService) *BorrowHandler { + return &BorrowHandler{svc: svc} +} + +// ListAll godoc +// @Summary List All Borrows +// @Description Retrieves all borrow records with pagination, sorting, and search. +// @Description +// @Description **Query Parameters:** +// @Description - page: Page number (default: 1) +// @Description - size: Items per page (default: 10, max: 100) +// @Description - sort: Field to sort by (borrowedAt, dueDate, status, createdAt) +// @Description - order: Sort order (asc, desc) +// @Description - search: Search by customer name or book title +// @Tags backoffice/borrows +// @Produce json +// @Param page query int false "Page number" +// @Param size query int false "Items per page" +// @Param sort query string false "Sort field" +// @Param order query string false "Sort order" +// @Param search query string false "Search term" +// @Success 200 {object} response.Response{data=[]domain.BorrowDetail} +// @Failure 500 {object} response.Response +// @Router /backoffice/borrows [get] +// @Security Bearer +func (h *BorrowHandler) ListAll(c *gin.Context) { + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + borrows, total, err := h.svc.GetAll(c.Request.Context(), params) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OKWithPagination(c, borrows, i18n.MsgFetched, total, params.Pagination.Page, params.Pagination.Size) +} + +// ListByCustomer godoc +// @Summary List Customer's Active Borrows +// @Description Returns all currently active borrows for a specific customer. +// @Tags backoffice/borrows +// @Produce json +// @Param id path string true "Customer UUID" +// @Success 200 {object} response.Response{data=[]domain.BorrowDetail} +// @Failure 404 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /backoffice/borrows/customers/{id} [get] +// @Security Bearer +func (h *BorrowHandler) ListByCustomer(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + borrows, err := h.svc.GetByCustomer(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, borrows, i18n.MsgFetched) +} + +// Borrow godoc +// @Summary Borrow a Book +// @Description Creates a new borrow record. +// @Description +// @Description **Business Rules:** +// @Description - Customer must be active +// @Description - Book must have available copies +// @Description - Customer cannot exceed 3 active borrows +// @Description - Customer cannot borrow the same book twice simultaneously +// @Description - Default loan period is 14 days +// @Tags backoffice/borrows +// @Accept json +// @Produce json +// @Param input body domain.CreateBorrowInput true "Borrow details" +// @Success 201 {object} response.Response{data=domain.Borrow} +// @Failure 400 {object} response.Response +// @Failure 404 {object} response.Response +// @Failure 409 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /backoffice/borrows [post] +// @Security Bearer +func (h *BorrowHandler) Borrow(c *gin.Context) { + var input domain.CreateBorrowInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New(). + Required("customerId", input.CustomerID). + Required("bookId", input.BookID) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + borrow, err := h.svc.Borrow(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.Created(c, borrow, i18n.MsgBorrowSuccess) +} + +// Return godoc +// @Summary Return a Book +// @Description Marks a borrow as returned and restores the book's available copies. +// @Tags backoffice/borrows +// @Produce json +// @Param id path string true "Borrow UUID" +// @Success 200 {object} response.Response{data=domain.Borrow} +// @Failure 404 {object} response.Response +// @Failure 409 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /backoffice/borrows/{id}/return [put] +// @Security Bearer +func (h *BorrowHandler) Return(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + borrow, err := h.svc.Return(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, borrow, i18n.MsgReturnSuccess) +} diff --git a/internal/handler/borrow_handler_test.go b/internal/handler/borrow_handler_test.go new file mode 100644 index 0000000..7b7834f --- /dev/null +++ b/internal/handler/borrow_handler_test.go @@ -0,0 +1,69 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func setupBorrowRouter( + borrowRepo *mocks.MockBorrowRepository, + bookRepo *mocks.MockBookRepository, + customerRepo *mocks.MockCustomerRepository, +) *gin.Engine { + svc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + h := handler.NewBorrowHandler(svc) + + r := helpers.NewRouter() + r.POST("/borrows", h.Borrow) + r.PUT("/borrows/:id/return", h.Return) + r.GET("/borrows", h.ListAll) + return r +} + +func TestBorrowHandler_Borrow_MissingCustomerID(t *testing.T) { + r := setupBorrowRouter( + new(mocks.MockBorrowRepository), + new(mocks.MockBookRepository), + new(mocks.MockCustomerRepository), + ) + + req := helpers.NewRequest(t, http.MethodPost, "/borrows", map[string]any{ + "bookId": "book-1", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestBorrowHandler_Borrow_Success(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + customerRepo.On("GetByID", anyCtx, "cust-1").Return(&domain.Customer{ID: "cust-1", Active: true}, nil) + bookRepo.On("GetByID", anyCtx, "book-1").Return(&domain.Book{ID: "book-1", AvailableCopies: 2}, nil) + borrowRepo.On("CountActiveByCustomer", anyCtx, "cust-1").Return(0, nil) + //borrowRepo.On("GetActiveByCustomer", anyCtx, "cust-1").Return([]domain.BorrowDetail{}, nil) + borrowRepo.On("Create", anyCtx, anyInput, domain.DefaultBorrowDays). + Return(&domain.Borrow{ID: "borrow-1"}, nil) + + r := setupBorrowRouter(borrowRepo, bookRepo, customerRepo) + req := helpers.NewRequest(t, http.MethodPost, "/borrows", map[string]any{ + "customerId": "cust-1", + "bookId": "book-1", + }) + w := helpers.Do(r, req) + + require.Equal(t, http.StatusCreated, w.Code) + borrowRepo.AssertExpectations(t) +} diff --git a/internal/handler/customer_auth_handler.go b/internal/handler/customer_auth_handler.go new file mode 100644 index 0000000..12026a4 --- /dev/null +++ b/internal/handler/customer_auth_handler.go @@ -0,0 +1,266 @@ +package handler + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type CustomerAuthHandler struct { + authSvc *service.AuthService + customerSvc *service.CustomerService +} + +func NewCustomerAuthHandler(authSvc *service.AuthService, customerSvc *service.CustomerService) *CustomerAuthHandler { + return &CustomerAuthHandler{ + authSvc: authSvc, + customerSvc: customerSvc, + } +} + +// Signup godoc +// @Summary Customer Signup +// @Description Creates a new customer account with email/mobile and password. +// @Tags portal/auth +// @Accept json +// @Produce json +// @Description **Required Fields:** +// @Description - firstName +// @Description - lastName +// @Description - email +// @Description - mobile +// @Description - password +// @Param input body domain.CustomerSignupInput true "Signup details" +// @Success 201 {object} response.Response{data=domain.SafeCustomer} "Account created successfully" +// @Failure 400 {object} response.Response "Invalid request or validation error" +// @Failure 409 {object} response.Response "Email or mobile already exists" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/signup [post] +func (h *CustomerAuthHandler) Signup(c *gin.Context) { + var input domain.CustomerSignupInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + v := validator.New(). + Required("firstName", input.FirstName). + Required("lastName", input.LastName). + Required("email", input.Email). + Email("email", input.Email). + Required("mobile", input.Mobile). + Required("password", input.Password). + Min("password", len(input.Password), 8) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + pair, customer, err := h.authSvc.SignupCustomer(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.Created(c, gin.H{"tokens": pair, "customer": customer.Safe()}, i18n.MsgCreated) +} + +// Login godoc +// @Summary Customer Login +// @Description Authenticates a customer using email or mobile with password. +// @Tags portal/auth +// @Accept json +// @Produce json +// @Param input body domain.CustomerLoginInput true "Login credentials" +// @Success 200 {object} response.Response{data=domain.LoginResponse} "Login successful" +// @Failure 400 {object} response.Response "Invalid request" +// @Failure 401 {object} response.Response "Invalid credentials" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/login [post] +func (h *CustomerAuthHandler) Login(c *gin.Context) { + var input domain.CustomerLoginInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + pair, customer, err := h.authSvc.LoginCustomer(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, gin.H{"tokens": pair, "customer": customer.Safe()}, i18n.MsgLoginSuccess) +} + +// Logout godoc +// @Summary Customer Logout +// @Description Invalidates the current customer session. +// @Tags portal/auth +// @Produce json +// @Success 204 "Logout successful" +// @Failure 401 {object} response.Response "Unauthorized" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/logout [post] +// @Security Bearer +func (h *CustomerAuthHandler) Logout(c *gin.Context) { + claims := h.getClaims(c) + if claims == nil { + return + } + if err := h.authSvc.Logout(c.Request.Context(), claims.ID, claims.UserID, domain.SubjectTypeCustomer); err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// Refresh godoc +// @Summary Refresh Customer Token +// @Description Issues a new token pair using a valid refresh token. +// @Tags portal/auth +// @Accept json +// @Produce json +// @Param input body domain.RefreshTokenInput true "Refresh token" +// @Success 200 {object} response.Response{data=domain.TokenPair} "Token refreshed successfully" +// @Failure 400 {object} response.Response "Invalid request" +// @Failure 401 {object} response.Response "Invalid or expired refresh token" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/refresh [post] +func (h *CustomerAuthHandler) Refresh(c *gin.Context) { + var input domain.RefreshTokenInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + pair, err := h.authSvc.Refresh(c.Request.Context(), input.RefreshToken, domain.SubjectTypeCustomer) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, pair, i18n.MsgLoginSuccess) +} + +// ForgotPassword godoc +// @Summary Forgot Password +// @Description Sends an OTP to the customer's email. Always returns success for security. +// @Tags portal/auth +// @Accept json +// @Produce json +// @Param input body domain.ForgotPasswordInput true "Email address" +// @Success 200 {object} response.Response "OTP sent successfully" +// @Failure 400 {object} response.Response "Invalid email format" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/forgot-password [post] +func (h *CustomerAuthHandler) ForgotPassword(c *gin.Context) { + var input domain.ForgotPasswordInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + v := validator.New().Required("email", input.Email).Email("email", input.Email) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + _ = h.authSvc.ForgotPassword(c.Request.Context(), input.Email, domain.SubjectTypeCustomer) + response.OK(c, nil, i18n.MsgFetched) +} + +// ResetPassword godoc +// @Summary Reset Password +// @Description Verifies OTP and updates the customer's password. Invalidates all active sessions. +// @Tags portal/auth +// @Accept json +// @Produce json +// @Param input body domain.ResetPasswordInput true "OTP and new password" +// @Success 204 "Password reset successfully" +// @Failure 400 {object} response.Response "Invalid request or validation error" +// @Failure 401 {object} response.Response "Invalid or expired OTP" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/reset-password [post] +func (h *CustomerAuthHandler) ResetPassword(c *gin.Context) { + var input domain.ResetPasswordInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + v := validator.New(). + Required("email", input.Email). + Required("otp", input.OTP). + Required("newPassword", input.NewPassword). + Min("newPassword", len(input.NewPassword), 8) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + if err := h.authSvc.ResetPassword(c.Request.Context(), input, domain.SubjectTypeCustomer); err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// GoogleRedirect godoc +// @Summary Google OAuth Redirect +// @Description Redirects customer to Google OAuth consent screen. +// @Tags portal/auth +// @Produce html +// @Success 302 "Redirect to Google OAuth" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/google [get] +func (h *CustomerAuthHandler) GoogleRedirect(c *gin.Context) { + b := make([]byte, 16) + _, _ = rand.Read(b) + state := base64.URLEncoding.EncodeToString(b) + // TODO: store state in Redis to verify in callback (CSRF protection) + c.Redirect(http.StatusTemporaryRedirect, h.authSvc.GoogleAuthURL(state)) +} + +// GoogleCallback godoc +// @Summary Google OAuth Callback +// @Description Handles Google OAuth callback and authenticates customer. +// @Tags portal/auth +// @Produce json +// @Param code query string true "Authorization code from Google" +// @Success 200 {object} response.Response{data=domain.LoginResponse} "Authentication successful" +// @Failure 400 {object} response.Response "Missing authorization code" +// @Failure 401 {object} response.Response "Authentication failed" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /portal/auth/google/callback [get] +func (h *CustomerAuthHandler) GoogleCallback(c *gin.Context) { + code := c.Query("code") + if code == "" { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + // TODO: verify state parameter from Redis to prevent CSRF attacks + // state := c.Query("state") + // if !h.authSvc.VerifyOAuthState(c.Request.Context(), state) { + // response.Unauthorized(c) + // return + // } + pair, customer, err := h.authSvc.GoogleAuth(c.Request.Context(), code) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, gin.H{"tokens": pair, "customer": customer.Safe()}, i18n.MsgLoginSuccess) +} + +func (h *CustomerAuthHandler) getClaims(c *gin.Context) *domain.Claims { + val, exists := c.Get("claims") + if !exists { + response.Unauthorized(c) + return nil + } + claims, ok := val.(*domain.Claims) + if !ok { + response.Unauthorized(c) + return nil + } + return claims +} diff --git a/internal/handler/customer_auth_handler_test.go b/internal/handler/customer_auth_handler_test.go new file mode 100644 index 0000000..a2c4119 --- /dev/null +++ b/internal/handler/customer_auth_handler_test.go @@ -0,0 +1,270 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func newCustomerAuthHandler( + customerRepo *mocks.MockCustomerRepository, +) *handler.CustomerAuthHandler { + authSvc := service.NewAuthService( + nil, + customerRepo, + nil, + nil, + nil, + nil, + "test-secret-key-32-bytes-long!!!", + time.Hour, + 24*time.Hour, + "", "", "", + ) + customerSvc := service.NewCustomerService(customerRepo) + return handler.NewCustomerAuthHandler(authSvc, customerSvc) +} + +func setupCustomerAuthRouter(h *handler.CustomerAuthHandler) *gin.Engine { + r := helpers.NewRouter() + r.POST("/portal/auth/signup", h.Signup) + r.POST("/portal/auth/login", h.Login) + r.POST("/portal/auth/forgot-password", h.ForgotPassword) + r.POST("/portal/auth/reset-password", h.ResetPassword) + r.GET("/portal/auth/google/callback", h.GoogleCallback) + return r +} + +func TestCustomerAuthHandler_Signup_ValidationFails_MissingFirstName(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/signup", map[string]any{ + "lastName": "Doe", + "email": "john@example.com", + "mobile": "09123456789", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_Signup_ValidationFails_InvalidEmail(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/signup", map[string]any{ + "firstName": "John", + "lastName": "Doe", + "email": "not-an-email", + "mobile": "09123456789", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_Signup_ValidationFails_ShortPassword(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/signup", map[string]any{ + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com", + "mobile": "09123456789", + "password": "short", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_Signup_EmailConflict(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(&domain.Customer{ID: "existing"}, nil) + + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/signup", map[string]any{ + "firstName": "John", + "lastName": "Doe", + "email": "taken@example.com", + "mobile": "09123456789", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestCustomerAuthHandler_Login_InvalidCredentials(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found")) + + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/login", map[string]any{ + "handler": "email", + "email": "nobody@example.com", + "password": "wrongpass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestCustomerAuthHandler_ForgotPassword_ValidationFails_InvalidEmail(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/forgot-password", map[string]any{ + "email": "not-valid", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_ForgotPassword_AlwaysSucceeds_UnknownEmail(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found")) + + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/forgot-password", map[string]any{ + "email": "unknown@example.com", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestCustomerAuthHandler_ResetPassword_ValidationFails_MissingOTP(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/reset-password", map[string]any{ + "email": "john@example.com", + "newPassword": "newpassword", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_ResetPassword_ValidationFails_ShortPassword(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/portal/auth/reset-password", map[string]any{ + "email": "john@example.com", + "otp": "123456", + "newPassword": "short", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerAuthHandler_GoogleCallback_MissingCode(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + h := newCustomerAuthHandler(repo) + r := setupCustomerAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodGet, "/portal/auth/google/callback", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +type mockCustomerRepoForAuth struct { + onGetByEmail func(ctx context.Context, email string) (*domain.Customer, error) +} + +func (m *mockCustomerRepoForAuth) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { + if m.onGetByEmail != nil { + return m.onGetByEmail(ctx, email) + } + return nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found") +} + +func (m *mockCustomerRepoForAuth) Create(_ context.Context, _ domain.CreateCustomerInput) (*domain.Customer, error) { + return nil, nil +} +func (m *mockCustomerRepoForAuth) GetByID(_ context.Context, _ string) (*domain.Customer, error) { + return nil, nil +} +func (m *mockCustomerRepoForAuth) GetByMobile(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found") +} +func (m *mockCustomerRepoForAuth) GetAll(_ context.Context, _ domain.QueryParams) ([]domain.Customer, int64, error) { + return nil, 0, nil +} +func (m *mockCustomerRepoForAuth) Update(_ context.Context, _ string, _ domain.UpdateCustomerInput) (*domain.Customer, error) { + return nil, nil +} +func (m *mockCustomerRepoForAuth) UpdateProfile(_ context.Context, _ string, _ domain.UpdateCustomerProfileInput) error { + return nil +} +func (m *mockCustomerRepoForAuth) Delete(_ context.Context, _ string) error { return nil } +func (m *mockCustomerRepoForAuth) UpdatePassword(_ context.Context, _, _ string) error { + return nil +} +func (m *mockCustomerRepoForAuth) GetByGoogleID(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found") +} +func (m *mockCustomerRepoForAuth) LinkGoogleID(_ context.Context, _, _ string) error { + return nil +} + +func TestMockCustomerRepoForAuth_Used(t *testing.T) { + repo := &mockCustomerRepoForAuth{ + onGetByEmail: func(_ context.Context, email string) (*domain.Customer, error) { + emailPtr := &email + return &domain.Customer{ID: "cust-1", Email: emailPtr}, nil + }, + } + + ctx := context.Background() + + c, err := repo.GetByEmail(ctx, "test@example.com") + assert.NoError(t, err) + assert.NotNil(t, c) + + _, _ = repo.Create(ctx, domain.CreateCustomerInput{}) + _, _ = repo.GetByID(ctx, "test-id") + _, _ = repo.GetByMobile(ctx, "09123456789") + _, _, _ = repo.GetAll(ctx, domain.QueryParams{}) + _, _ = repo.Update(ctx, "test-id", domain.UpdateCustomerInput{}) + _ = repo.UpdateProfile(ctx, "test-id", domain.UpdateCustomerProfileInput{}) + _ = repo.Delete(ctx, "test-id") + _ = repo.UpdatePassword(ctx, "test-id", "newpassword") + _, _ = repo.GetByGoogleID(ctx, "google-id") + _ = repo.LinkGoogleID(ctx, "test-id", "google-id") +} diff --git a/internal/handler/customer_handler.go b/internal/handler/customer_handler.go new file mode 100644 index 0000000..77a2b7a --- /dev/null +++ b/internal/handler/customer_handler.go @@ -0,0 +1,243 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type CustomerHandler struct { + svc *service.CustomerService +} + +func NewCustomerHandler(svc *service.CustomerService) *CustomerHandler { + return &CustomerHandler{svc: svc} +} + +// List godoc +// @Summary List All Customers +// @Description Retrieves a list of all customers in the library with pagination, sorting, and filtering. +// @Description +// @Description **Query Parameters:** +// @Description - page: Page number (default: 1) +// @Description - size: Items per page (default: 10, max: 100) +// @Description - sort: Field to sort by (firstName, lastName, email, mobile, createdAt) +// @Description - order: Sort order (asc, desc, default: desc) +// @Description - search: Search in firstName, lastName, email, or mobile +// @Tags backoffice/customers +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param size query int false "Items per page" +// @Param sort query string false "Sort field" +// @Param order query string false "Sort order" +// @Param search query string false "Search term" +// @Success 200 {object} response.Response{data=[]domain.Customer} "Customers retrieved successfully" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/customers [get] +// @Security Bearer +func (h *CustomerHandler) List(c *gin.Context) { + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + customers, total, err := h.svc.GetAll(c.Request.Context(), params) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OKWithPagination(c, customers, i18n.MsgCustomerFound, total, params.Pagination.Page, params.Pagination.Size) +} + +// GetByID godoc +// @Summary Get Customer by ID +// @Description Retrieves a specific customer by its UUID. +// @Tags backoffice/customers +// @Accept json +// @Produce json +// @Param id path string true "Customer UUID" +// @Success 200 {object} response.Response{data=domain.Customer} "Customer retrieved successfully" +// @Failure 404 {object} response.Response "Customer not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/customers/{id} [get] +// @Security Bearer +func (h *CustomerHandler) GetByID(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + customer, err := h.svc.GetByID(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, customer, i18n.MsgCustomerFound) +} + +// Create godoc +// @Summary Create Customer +// @Description Creates a new customer in the library. A default password will be set automatically. +// @Description +// @Description **Required Fields:** +// @Description - firstName +// @Description - lastName +// @Description - nationalCode +// @Description - email +// @Description - birthDate (ISO 8601 date format: YYYY-MM-DD) +// @Description +// @Description **Optional Fields:** +// @Description - mobile (must be unique if provided) +// @Description - address +// @Tags backoffice/customers +// @Accept json +// @Produce json +// @Param input body domain.CreateCustomerInput true "Customer details" +// @Success 201 {object} response.Response{data=domain.Customer} "Customer created successfully" +// @Failure 400 {object} response.Response "Invalid request or validation error" +// @Failure 409 {object} response.Response "Email or mobile already exists" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/customers [post] +// @Security Bearer +func (h *CustomerHandler) Create(c *gin.Context) { + var input domain.CreateCustomerInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + //TODO: update this validator. + emailVal := "" + if input.Email != nil { + emailVal = *input.Email + } + mobileVal := "" + if input.Mobile != nil { + mobileVal = *input.Mobile + } + v := validator.New(). + Required("firstName", input.FirstName). + Required("lastName", input.LastName). + Required("birthDate", func() string { + if input.BirthDate != nil { + return input.BirthDate.Format("2006-01-02") + } + return "" + }()). + Required("nationalCode", func() string { + if input.NationalCode != nil { + return *input.NationalCode + } + return "" + }()). + Required("email", emailVal). + Email("email", emailVal). + MaxLen("mobile", mobileVal, 11) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + customer, err := h.svc.Create(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.Created(c, customer, i18n.MsgCustomerCreated) +} + +// Update godoc +// @Summary Update Customer +// @Description Updates specific fields of an existing customer. Only address and active fields can be updated. +// @Description +// @Description **Updatable Fields:** +// @Description - address +// @Description - active +// @Description +// @Description **Business Rules:** +// @Description - Customer must exist in the system +// @Description - Other fields (firstName, lastName, email, mobile, nationalCode, birthDate) cannot be updated +// @Tags backoffice/customers +// @Accept json +// @Produce json +// @Param id path string true "Customer UUID" +// @Param input body domain.UpdateCustomerInput true "Fields to update" +// @Success 200 {object} response.Response{data=domain.Customer} "Customer updated successfully" +// @Failure 400 {object} response.Response "Invalid request" +// @Failure 404 {object} response.Response "Customer not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/customers/{id} [put] +// @Security Bearer +func (h *CustomerHandler) Update(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + var input domain.UpdateCustomerInput + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + log.Printf("[customer] update decode error: %v", err) + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + if input.Address == nil && input.Active == nil { + response.Error(c, http.StatusUnprocessableEntity, i18n.MsgBadRequest) + return + } + + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + customer, err := h.svc.Update(c.Request.Context(), id, input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, customer, i18n.MsgCustomerUpdated) +} + +// Delete godoc +// @Summary Delete Customer +// @Description Permanently deletes a customer from the library by its UUID. +// @Description +// @Description **Business Rules:** +// @Description - Customer must exist in the system +// @Description - Deletion is permanent and cannot be undone +// @Tags backoffice/customers +// @Accept json +// @Produce json +// @Param id path string true "Customer UUID" +// @Success 204 "Customer deleted successfully" +// @Failure 404 {object} response.Response "Customer not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/customers/{id} [delete] +// @Security Bearer +func (h *CustomerHandler) Delete(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + err := h.svc.Delete(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/handler/customer_handler_test.go b/internal/handler/customer_handler_test.go new file mode 100644 index 0000000..2a38e67 --- /dev/null +++ b/internal/handler/customer_handler_test.go @@ -0,0 +1,160 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" +) + +func setupCustomerRouter(svc *service.CustomerService) *gin.Engine { + h := handler.NewCustomerHandler(svc) + r := helpers.NewRouter() + r.GET("/customers", h.List) + r.GET("/customers/:id", h.GetByID) + r.POST("/customers", h.Create) + r.PUT("/customers/:id", h.Update) + r.DELETE("/customers/:id", h.Delete) + return r +} + +func TestCustomerHandler_Create_ValidationFails_MissingFirstName(t *testing.T) { + repo := new(mockCustomerRepo) + svc := service.NewCustomerService(repo) + + r := setupCustomerRouter(svc) + req := helpers.NewRequest(t, http.MethodPost, "/customers", map[string]any{ + "lastName": "Doe", + "email": "john@example.com", + "nationalCode": "1234567890", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestCustomerHandler_Create_Success(t *testing.T) { + repo := new(mockCustomerRepo) + svc := service.NewCustomerService(repo) + + birthDate := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC) + email := "john@example.com" + emailPtr := &email + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + Email: emailPtr, + BirthDate: &birthDate, + Active: true, + } + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found") + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found") + } + repo.onCreate = func(_ context.Context, _ domain.CreateCustomerInput) (*domain.Customer, error) { + return customer, nil + } + + r := setupCustomerRouter(svc) + req := helpers.NewRequest(t, http.MethodPost, "/customers", map[string]any{ + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com", + "nationalCode": "1234567890", + "birthDate": "1990-01-01T00:00:00Z", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + +type mockCustomerRepo struct { + onCreate func(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) + onGetByID func(ctx context.Context, id string) (*domain.Customer, error) + onGetByEmail func(ctx context.Context, email string) (*domain.Customer, error) + onGetByMobile func(ctx context.Context, mobile string) (*domain.Customer, error) + onGetAll func(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) + onUpdate func(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) + onUpdateProfile func(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error + onDelete func(ctx context.Context, id string) error +} + +func (m *mockCustomerRepo) Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) { + if m.onCreate != nil { + return m.onCreate(ctx, input) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByID(ctx context.Context, id string) (*domain.Customer, error) { + if m.onGetByID != nil { + return m.onGetByID(ctx, id) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { + if m.onGetByEmail != nil { + return m.onGetByEmail(ctx, email) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) { + if m.onGetByMobile != nil { + return m.onGetByMobile(ctx, mobile) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) { + if m.onGetAll != nil { + return m.onGetAll(ctx, params) + } + return nil, 0, nil +} + +func (m *mockCustomerRepo) Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) { + if m.onUpdate != nil { + return m.onUpdate(ctx, id, input) + } + return nil, nil +} + +func (m *mockCustomerRepo) UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error { + if m.onUpdateProfile != nil { + return m.onUpdateProfile(ctx, id, input) + } + return nil +} + +func (m *mockCustomerRepo) Delete(ctx context.Context, id string) error { + if m.onDelete != nil { + return m.onDelete(ctx, id) + } + return nil +} + +func (m *mockCustomerRepo) GetByGoogleID(_ context.Context, _ string) (*domain.Customer, error) { + return nil, nil +} + +func (m *mockCustomerRepo) LinkGoogleID(_ context.Context, _ string, _ string) error { + return nil +} + +func (m *mockCustomerRepo) UpdatePassword(_ context.Context, _ string, _ string) error { + return nil +} diff --git a/internal/handler/employee_auth_handler.go b/internal/handler/employee_auth_handler.go new file mode 100644 index 0000000..728d2bb --- /dev/null +++ b/internal/handler/employee_auth_handler.go @@ -0,0 +1,201 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type EmployeeAuthHandler struct { + authSvc *service.AuthService +} + +func NewEmployeeAuthHandler(authSvc *service.AuthService) *EmployeeAuthHandler { + return &EmployeeAuthHandler{authSvc: authSvc} +} + +type employeeLoginResponse struct { + Tokens *domain.TokenPair `json:"tokens"` + Employee domain.SafeEmployee `json:"employee"` +} + +// LoginViaPassword godoc +// @Summary Employee Login +// @Description Authenticate via email or mobile + password. +// @Tags backoffice/auth +// @Accept json +// @Produce json +// @Param input body domain.EmployeeLoginInput true "Login credentials" +// @Success 200 {object} response.Response{data=employeeLoginResponse} +// @Failure 401 {object} response.Response +// @Failure 422 {object} response.Response +// @Router /backoffice/auth/login [post] +func (h *EmployeeAuthHandler) LoginViaPassword(c *gin.Context) { + var input domain.EmployeeLoginInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New().Required("handler", input.Handler) + switch input.Handler { + case "email": + v.Required("email", input.Email).Email("email", input.Email) + case "mobile": + v.Required("mobile", input.Mobile).MaxLen("mobile", input.Mobile, 11) + default: + v.Custom("handler", true, "must be 'email' or 'mobile'") + } + v.Required("password", input.Password) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + pair, employee, err := h.authSvc.LoginEmployee(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + + response.OK(c, employeeLoginResponse{ + Tokens: pair, + Employee: employee.Safe(), + }, i18n.MsgLoginSuccess) +} + +// Logout godoc +// @Summary Employee Logout +// @Description Invalidates the current session. +// @Tags backoffice/auth +// @Produce json +// @Success 204 +// @Failure 401 {object} response.Response +// @Router /backoffice/auth/logout [post] +// @Security Bearer +func (h *EmployeeAuthHandler) Logout(c *gin.Context) { + claims, ok := h.getClaims(c) + if !ok { + return + } + if err := h.authSvc.Logout(c.Request.Context(), claims.ID, claims.UserID, domain.SubjectTypeEmployee); err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// Refresh godoc +// @Summary Refresh Access Token +// @Description Issues a new token pair using a valid refresh token. +// @Tags backoffice/auth +// @Accept json +// @Produce json +// @Param input body domain.RefreshTokenInput true "Refresh token" +// @Success 200 {object} response.Response{data=domain.TokenPair} +// @Failure 401 {object} response.Response +// @Router /backoffice/auth/refresh [post] +func (h *EmployeeAuthHandler) Refresh(c *gin.Context) { + var input domain.RefreshTokenInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New().Required("refreshToken", input.RefreshToken) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + pair, err := h.authSvc.Refresh(c.Request.Context(), input.RefreshToken, domain.SubjectTypeEmployee) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, pair, i18n.MsgLoginSuccess) +} + +// ForgotPassword godoc +// @Summary Forgot Password +// @Description Sends an OTP to the employee's email. Always returns success. +// @Tags backoffice/auth +// @Accept json +// @Produce json +// @Param input body domain.ForgotPasswordInput true "Email" +// @Success 200 {object} response.Response +// @Router /backoffice/auth/forgot-password [post] +func (h *EmployeeAuthHandler) ForgotPassword(c *gin.Context) { + var input domain.ForgotPasswordInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New().Required("email", input.Email).Email("email", input.Email) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + _ = h.authSvc.ForgotPassword(c.Request.Context(), input.Email, domain.SubjectTypeEmployee) + response.OK(c, nil, i18n.MsgFetched) +} + +// ResetPassword godoc +// @Summary Reset Password +// @Description Verifies OTP and updates the employee's password. Invalidates all active sessions. +// @Tags backoffice/auth +// @Accept json +// @Produce json +// @Param input body domain.ResetPasswordInput true "OTP and new password" +// @Success 204 +// @Failure 401 {object} response.Response +// @Failure 422 {object} response.Response +// @Router /backoffice/auth/reset-password [post] +func (h *EmployeeAuthHandler) ResetPassword(c *gin.Context) { + var input domain.ResetPasswordInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New(). + Required("email", input.Email). + Email("email", input.Email). + Required("otp", input.OTP). + Required("newPassword", input.NewPassword). + Min("newPassword", len(input.NewPassword), 8) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + if err := h.authSvc.ResetPassword(c.Request.Context(), input, domain.SubjectTypeEmployee); err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *EmployeeAuthHandler) getClaims(c *gin.Context) (*domain.Claims, bool) { + val, exists := c.Get("claims") + if !exists { + response.Unauthorized(c) + return nil, false + } + claims, ok := val.(*domain.Claims) + if !ok { + response.Unauthorized(c) + return nil, false + } + return claims, true +} diff --git a/internal/handler/employee_auth_handler_test.go b/internal/handler/employee_auth_handler_test.go new file mode 100644 index 0000000..92777fe --- /dev/null +++ b/internal/handler/employee_auth_handler_test.go @@ -0,0 +1,197 @@ +package handler_test + +import ( + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func newEmployeeAuthHandler(employeeRepo *mocks.MockEmployeeRepository) *handler.EmployeeAuthHandler { + authSvc := service.NewAuthService( + employeeRepo, + nil, + nil, + nil, + nil, + nil, + "test-secret-key-32-bytes-long!!!", + time.Hour, + 24*time.Hour, + "", "", "", + ) + return handler.NewEmployeeAuthHandler(authSvc) +} + +func setupEmployeeAuthRouter(h *handler.EmployeeAuthHandler) *gin.Engine { + r := helpers.NewRouter() + r.POST("/backoffice/auth/login", h.LoginViaPassword) + r.POST("/backoffice/auth/forgot-password", h.ForgotPassword) + r.POST("/backoffice/auth/reset-password", h.ResetPassword) + r.POST("/backoffice/auth/refresh", h.Refresh) + return r +} + +func TestEmployeeAuthHandler_Login_ValidationFails_MissingHandler(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/login", map[string]any{ + "email": "alice@example.com", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_Login_ValidationFails_InvalidHandlerValue(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/login", map[string]any{ + "handler": "phone", + "email": "alice@example.com", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_Login_ValidationFails_InvalidEmail(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/login", map[string]any{ + "handler": "email", + "email": "not-an-email", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_Login_InvalidCredentials(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(nil, customError.NewNotFound(customError.ErrEmployeeNotFound, "not found")) + + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/login", map[string]any{ + "handler": "email", + "email": "nobody@example.com", + "password": "wrongpass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestEmployeeAuthHandler_Login_InactiveEmployee(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(&domain.Employee{ + ID: "emp-1", + Email: "alice@example.com", + Password: "$2a$10$hashedpassword", + Active: false, + Role: domain.RoleLibrarian, + }, nil) + + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/login", map[string]any{ + "handler": "email", + "email": "alice@example.com", + "password": "securepass", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestEmployeeAuthHandler_ForgotPassword_ValidationFails_InvalidEmail(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/forgot-password", map[string]any{ + "email": "not-valid", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_ForgotPassword_AlwaysSucceeds_UnknownEmail(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + repo.On("GetByEmail", anyCtx, anyInput). + Return(nil, customError.NewNotFound(customError.ErrEmployeeNotFound, "not found")) + + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/forgot-password", map[string]any{ + "email": "unknown@example.com", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestEmployeeAuthHandler_ResetPassword_ValidationFails_MissingOTP(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/reset-password", map[string]any{ + "email": "alice@example.com", + "newPassword": "newpassword", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_ResetPassword_ValidationFails_ShortPassword(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/reset-password", map[string]any{ + "email": "alice@example.com", + "otp": "123456", + "newPassword": "short", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} + +func TestEmployeeAuthHandler_Refresh_ValidationFails_MissingToken(t *testing.T) { + repo := new(mocks.MockEmployeeRepository) + h := newEmployeeAuthHandler(repo) + r := setupEmployeeAuthRouter(h) + + req := helpers.NewRequest(t, http.MethodPost, "/backoffice/auth/refresh", map[string]any{}) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) +} diff --git a/internal/handler/employee_handler.go b/internal/handler/employee_handler.go new file mode 100644 index 0000000..c4d759e --- /dev/null +++ b/internal/handler/employee_handler.go @@ -0,0 +1,228 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/validator" +) + +type EmployeeHandler struct { + svc *service.EmployeeService +} + +func NewEmployeeHandler(svc *service.EmployeeService) *EmployeeHandler { + return &EmployeeHandler{svc: svc} +} + +// List godoc +// @Summary List All Employees +// @Description Retrieves a list of all employees in the library with pagination, sorting, and filtering. +// @Description +// @Description **Query Parameters:** +// @Description - page: Page number (default: 1) +// @Description - size: Items per page (default: 10, max: 100) +// @Description - sort: Field to sort by (firstName, lastName, email, role, createdAt) +// @Description - order: Sort order (asc, desc, default: desc) +// @Description - search: Search in firstName, lastName, or email +// @Tags backoffice/employees +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param size query int false "Items per page" +// @Param sort query string false "Sort field" +// @Param order query string false "Sort order" +// @Param search query string false "Search term" +// @Success 200 {object} response.Response{data=[]domain.SafeEmployee} "Employees retrieved successfully" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/employees [get] +// @Security Bearer +func (h *EmployeeHandler) List(c *gin.Context) { + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + employees, total, err := h.svc.GetAll(c.Request.Context(), params) + if err != nil { + response.HandleAppError(c, err) + return + } + safe := make([]domain.SafeEmployee, len(employees)) + for i, e := range employees { + safe[i] = e.Safe() + } + response.OKWithPagination(c, safe, i18n.MsgEmployeeFound, total, params.Pagination.Page, params.Pagination.Size) +} + +// GetByID godoc +// @Summary Get Employee by ID +// @Description Retrieves a specific employee by its UUID. +// @Tags backoffice/employees +// @Accept json +// @Produce json +// @Param id path string true "Employee UUID" +// @Success 200 {object} response.Response{data=domain.SafeEmployee} "Employee retrieved successfully" +// @Failure 404 {object} response.Response "Employee not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/employees/{id} [get] +// @Security Bearer +func (h *EmployeeHandler) GetByID(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + e, err := h.svc.GetByID(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, e.Safe(), i18n.MsgEmployeeFound) +} + +// Create godoc +// @Summary Create Employee +// @Description Creates a new employee in the library. +// @Description +// @Description **Required Fields:** +// @Description - firstName +// @Description - lastName +// @Description - email +// @Description - mobile +// @Description - nationalCode +// @Description - birthDate (ISO 8601 date format: YYYY-MM-DD) +// @Description - password (min 8 characters) +// @Description +// @Description **Optional Fields:** +// @Description - role (defaults to librarian if not provided) +// @Tags backoffice/employees +// @Accept json +// @Produce json +// @Param input body domain.CreateEmployeeInput true "Employee details" +// @Success 201 {object} response.Response{data=domain.SafeEmployee} "Employee created successfully" +// @Failure 400 {object} response.Response "Invalid request or validation error" +// @Failure 409 {object} response.Response "Email, mobile, or national code already exists" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/employees [post] +// @Security Bearer +func (h *EmployeeHandler) Create(c *gin.Context) { + var input domain.CreateEmployeeInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + v := validator.New(). + Required("firstName", input.FirstName). + Required("lastName", input.LastName). + Required("email", input.Email). + Email("email", input.Email). + Required("mobile", input.Mobile). + Required("nationalCode", input.NationalCode). + Required("birthDate", func() string { + if input.BirthDate != nil { + return input.BirthDate.Format("2006-01-02") + } + return "" + }()). + Required("password", input.Password). + Min("password_length", len(input.Password), 8) + + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + + e, err := h.svc.Create(c.Request.Context(), input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.Created(c, e.Safe(), i18n.MsgEmployeeCreated) +} + +// Update godoc +// @Summary Update Employee +// @Description Updates specific fields of an existing employee. +// @Description +// @Description **Updatable Fields:** +// @Description - email +// @Description - role +// @Description - active +// @Description +// @Description **Business Rules:** +// @Description - Employee must exist in the system +// @Description - Email must be unique if updated +// @Tags backoffice/employees +// @Accept json +// @Produce json +// @Param id path string true "Employee UUID" +// @Param input body domain.UpdateEmployeeInput true "Fields to update" +// @Success 200 {object} response.Response{data=domain.SafeEmployee} "Employee updated successfully" +// @Failure 400 {object} response.Response "Invalid request" +// @Failure 404 {object} response.Response "Employee not found" +// @Failure 409 {object} response.Response "Email already exists" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/employees/{id} [put] +// @Security Bearer +func (h *EmployeeHandler) Update(c *gin.Context) { + var input domain.UpdateEmployeeInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + if input.Email != nil { + v := validator.New().Email("email", *input.Email) + if v.HasErrors() { + response.ValidationError(c, v.Errors()) + return + } + } + + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + e, err := h.svc.Update(c.Request.Context(), id, input) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, e.Safe(), i18n.MsgEmployeeUpdated) +} + +// Delete godoc +// @Summary Delete Employee +// @Description Permanently deletes an employee from the library by its UUID. +// @Description +// @Description **Business Rules:** +// @Description - Employee must exist in the system +// @Description - Deletion is permanent and cannot be undone +// @Tags backoffice/employees +// @Accept json +// @Produce json +// @Param id path string true "Employee UUID" +// @Success 204 "Employee deleted successfully" +// @Failure 404 {object} response.Response "Employee not found" +// @Failure 500 {object} response.Response "Internal server error" +// @Router /backoffice/employees/{id} [delete] +// @Security Bearer +func (h *EmployeeHandler) Delete(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + err := h.svc.Delete(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go new file mode 100644 index 0000000..a831128 --- /dev/null +++ b/internal/handler/health_handler.go @@ -0,0 +1,110 @@ +package handler + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/elastic/go-elasticsearch/v8" + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" +) + +type HealthHandler struct { + db *sqlx.DB + rdb *redis.Client + esClient *elasticsearch.Client + version string + appName string +} + +func NewHealthHandler( + db *sqlx.DB, + rdb *redis.Client, + esClient *elasticsearch.Client, + version string, + appName string, +) *HealthHandler { + return &HealthHandler{ + db: db, + rdb: rdb, + esClient: esClient, + version: version, + appName: appName, + } +} + +type healthCheck struct { + Status string `json:"status"` + Version string `json:"version"` + App string `json:"app"` + Checks map[string]string `json:"checks"` +} + +// Health godoc +// @Summary Health Check +// @Description Returns the live status of the API and all dependencies. +// @Tags system +// @Produce json +// @Success 200 {object} healthCheck "All systems operational" +// @Success 206 {object} healthCheck "Partial — some non-critical services degraded" +// @Failure 503 {object} healthCheck "Critical dependency unavailable" +// @Router /health [get] +func (h *HealthHandler) Health(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + checks := map[string]string{} + overall := "ok" + + if err := h.db.PingContext(ctx); err != nil { + checks["postgres"] = "unavailable" + overall = "unavailable" + } else { + checks["postgres"] = "ok" + } + + if err := h.rdb.Ping(ctx).Err(); err != nil { + checks["redis"] = "unavailable" + overall = "unavailable" + } else { + checks["redis"] = "ok" + } + + res, err := h.esClient.Ping(h.esClient.Ping.WithContext(ctx)) + if err != nil || (res != nil && res.IsError()) { + checks["elasticsearch"] = "degraded" + if overall == "ok" { + overall = "degraded" + } + } else { + checks["elasticsearch"] = "ok" + if res != nil { + err := res.Body.Close() + if err != nil { + log.Println(err) + } + } + } + + payload := healthCheck{ + Status: overall, + Version: h.version, + App: h.appName, + Checks: checks, + } + + status := http.StatusOK + switch overall { + case "degraded": + status = http.StatusPartialContent + case "unavailable": + status = http.StatusServiceUnavailable + } + + c.JSON(status, gin.H{"data": payload, "meta": gin.H{ + "timestamp": time.Now().UTC().Format(time.RFC3339), + }}) +} diff --git a/internal/handler/health_handler_test.go b/internal/handler/health_handler_test.go new file mode 100644 index 0000000..f2a0a05 --- /dev/null +++ b/internal/handler/health_handler_test.go @@ -0,0 +1,32 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/tests/helpers" +) + +func setupHealthRouter() *gin.Engine { + r := helpers.NewRouter() + r.GET("/health", handler.NewHealthHandler(nil, nil, nil, "1.0.0", "test").Health) + return r +} + +func TestHealth_Success(t *testing.T) { + t.Skip("requires running infrastructure (postgres, redis, elasticsearch)") + r := setupHealthRouter() + req := helpers.NewRequest(t, http.MethodGet, "/health", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + assert.Equal(t, "ok", data["status"]) +} diff --git a/internal/handler/portal_book_handler.go b/internal/handler/portal_book_handler.go new file mode 100644 index 0000000..346e183 --- /dev/null +++ b/internal/handler/portal_book_handler.go @@ -0,0 +1,147 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/search" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +type PortalBookHandler struct { + bookSvc *service.BookService + querier *search.Querier +} + +func NewPortalBookHandler(bookSvc *service.BookService, querier *search.Querier) *PortalBookHandler { + return &PortalBookHandler{bookSvc: bookSvc, querier: querier} +} + +// List godoc +// @Summary Browse Books +// @Description Paginated list of books with public info. No auth required. +// @Tags portal/books +// @Produce json +// @Param page query int false "Page" +// @Param size query int false "Size" +// @Param sort query string false "Sort field" +// @Param order query string false "asc | desc" +// @Success 200 {object} response.Response{data=[]domain.PublicBook} +// @Router /portal/books [get] +func (h *PortalBookHandler) List(c *gin.Context) { + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + books, total, err := h.bookSvc.GetAll(c.Request.Context(), params) + if err != nil { + response.HandleAppError(c, err) + return + } + + public := make([]domain.PublicBook, len(books)) + for i, b := range books { + public[i] = b.Public() + } + + response.OKWithPagination(c, public, i18n.MsgBookFound, total, params.Pagination.Page, params.Pagination.Size) +} + +// GetByID godoc +// @Summary Get Book Detail +// @Description Returns public book info by ID. No auth required. +// @Tags portal/books +// @Produce json +// @Param id path string true "Book UUID" +// @Success 200 {object} response.Response{data=domain.PublicBook} +// @Failure 404 {object} response.Response +// @Router /portal/books/{id} [get] +func (h *PortalBookHandler) GetByID(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + book, err := h.bookSvc.GetByID(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, book.Public(), i18n.MsgBookFound) +} + +// Search godoc +// @Summary Search Books (Portal) +// @Description Full-text Elasticsearch search returning public book info. +// @Tags portal/books +// @Produce json +// @Param q query string false "Search query" +// @Param fuzzy query bool false "Enable fuzzy" +// @Param page query int false "Page" +// @Param size query int false "Size" +// @Success 200 {object} response.Response +// @Router /portal/books/search [get] +func (h *PortalBookHandler) Search(c *gin.Context) { + params := buildSearchParams(c) + + result, err := h.querier.SearchBooks(c.Request.Context(), params) + if err != nil { + response.InternalError(c) + return + } + + public := make([]map[string]any, len(result.Documents)) + for i, doc := range result.Documents { + public[i] = map[string]any{ + "id": doc.ID, + "title": doc.Title, + "author": doc.Author, + "genre": doc.Genre, + "description": doc.Description, + "language": doc.Language, + "publisher": doc.Publisher, + "publicationYear": doc.PublicationYear, + "pages": doc.Pages, + "isAvailable": doc.IsAvailable, + } + } + + response.OKWithPagination(c, public, i18n.MsgFetched, result.Total, params.Page, params.Size) +} + +// Suggest godoc +// @Summary Book Autocomplete (Portal) +// @Tags portal/books +// @Produce json +// @Param q query string true "Partial query (min 2 chars)" +// @Success 200 {object} response.Response +// @Router /portal/books/suggest [get] +func (h *PortalBookHandler) Suggest(c *gin.Context) { + q := c.Query("q") + if len(q) < 2 { + response.OK(c, []any{}, i18n.MsgFetched) + return + } + results, err := h.querier.SuggestBooks(c.Request.Context(), q) + if err != nil { + response.InternalError(c) + return + } + response.OK(c, results, i18n.MsgFetched) +} + +func buildSearchParams(c *gin.Context) search.Params { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + return search.Params{ + Query: c.Query("q"), + Fuzzy: c.Query("fuzzy") == "true", + Page: page, + Size: size, + } +} diff --git a/internal/handler/portal_book_handler_test.go b/internal/handler/portal_book_handler_test.go new file mode 100644 index 0000000..b008dd9 --- /dev/null +++ b/internal/handler/portal_book_handler_test.go @@ -0,0 +1,135 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +const ( + testPortalBookID = "00000000-0000-0000-0000-000000000001" + testPortalBookID2 = "00000000-0000-0000-0000-000000000002" +) + +func setupPortalBookRouter(repo *mocks.MockBookRepository) *gin.Engine { + svc := service.NewBookService(repo, nil) + h := handler.NewPortalBookHandler(svc, nil) + + r := helpers.NewRouter() + r.GET("/portal/books", h.List) + r.GET("/portal/books/:id", h.GetByID) + return r +} + +func TestPortalBookHandler_List_Success(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetAll", anyCtx, mock.AnythingOfType("domain.QueryParams")). + Return([]domain.Book{ + {ID: testPortalBookID, Title: "Dune", Author: "Frank Herbert", AvailableCopies: 3}, + {ID: testPortalBookID2, Title: "Foundation", Author: "Isaac Asimov", AvailableCopies: 1}, + }, int64(2), nil) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 2) +} + +func TestPortalBookHandler_List_Empty(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetAll", anyCtx, mock.AnythingOfType("domain.QueryParams")). + Return([]domain.Book{}, int64(0), nil) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Empty(t, data) +} + +func TestPortalBookHandler_GetByID_Success(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetByID", anyCtx, testPortalBookID).Return(&domain.Book{ + ID: testPortalBookID, + Title: "Dune", + Author: "Frank Herbert", + }, nil) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books/"+testPortalBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + assert.Equal(t, testPortalBookID, data["id"]) + assert.Equal(t, "Dune", data["title"]) +} + +func TestPortalBookHandler_GetByID_NotFound(t *testing.T) { + repo := new(mocks.MockBookRepository) + repo.On("GetByID", anyCtx, testPortalBookID). + Return(nil, customError.NewNotFound(customError.ErrBookNotFound, "book not found")) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books/"+testPortalBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestPortalBookHandler_GetByID_InvalidUUID(t *testing.T) { + repo := new(mocks.MockBookRepository) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books/not-a-uuid", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + repo.AssertNotCalled(t, "GetByID") +} + +func TestPortalBookHandler_GetByID_DoesNotExposeISBN(t *testing.T) { + repo := new(mocks.MockBookRepository) + isbn := "978-0441013593" + repo.On("GetByID", anyCtx, testPortalBookID).Return(&domain.Book{ + ID: testPortalBookID, + Title: "Dune", + ISBN: &isbn, + }, nil) + + r := setupPortalBookRouter(repo) + req := helpers.NewRequest(t, http.MethodGet, "/portal/books/"+testPortalBookID, nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + _, hasISBN := data["isbn"] + assert.False(t, hasISBN, "ISBN should not be exposed in portal response") +} diff --git a/internal/handler/portal_borrow_handler.go b/internal/handler/portal_borrow_handler.go new file mode 100644 index 0000000..3903e90 --- /dev/null +++ b/internal/handler/portal_borrow_handler.go @@ -0,0 +1,55 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +type PortalBorrowHandler struct { + borrowSvc *service.BorrowService +} + +func NewPortalBorrowHandler(svc *service.BorrowService) *PortalBorrowHandler { + return &PortalBorrowHandler{borrowSvc: svc} +} + +// GetMyBorrows godoc +// @Summary My Borrow History +// @Description Returns all borrows for the authenticated customer with optional status filter. +// @Description +// @Description **Query Parameters:** +// @Description - status: Filter by status (active | returned | overdue). Omit for all. +// @Tags portal/borrows +// @Produce json +// @Param status query string false "active | returned | overdue" +// @Param page query int false "Page" +// @Param size query int false "Size" +// @Success 200 {object} response.Response{data=[]domain.BorrowDetail} +// @Router /portal/me/borrows [get] +// @Security Bearer +func (h *PortalBorrowHandler) GetMyBorrows(c *gin.Context) { + customerID := getCustomerID(c) + if customerID == "" { + return + } + + var params domain.QueryParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + status := c.Query("status") + + borrows, total, err := h.borrowSvc.GetMyBorrows(c.Request.Context(), customerID, status, params) + if err != nil { + response.HandleAppError(c, err) + return + } + + response.OKWithPagination(c, borrows, i18n.MsgFetched, total, params.Pagination.Page, params.Pagination.Size) +} diff --git a/internal/handler/portal_borrow_handler_test.go b/internal/handler/portal_borrow_handler_test.go new file mode 100644 index 0000000..f679dd3 --- /dev/null +++ b/internal/handler/portal_borrow_handler_test.go @@ -0,0 +1,116 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func setupPortalBorrowRouter( + borrowRepo *mocks.MockBorrowRepository, + bookRepo *mocks.MockBookRepository, + customerRepo *mocks.MockCustomerRepository, + customerID string, +) *gin.Engine { + svc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + h := handler.NewPortalBorrowHandler(svc) + + r := helpers.NewRouter() + r.GET("/portal/me/borrows", func(c *gin.Context) { + c.Set("claims", &domain.Claims{ + UserID: customerID, + SubjectType: domain.SubjectTypeCustomer, + }) + h.GetMyBorrows(c) + }) + return r +} + +func TestPortalBorrowHandler_GetMyBorrows_Success(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + borrowRepo.On("GetAllByCustomer", anyCtx, "cust-1", "", mock.AnythingOfType("domain.QueryParams")). + Return([]domain.BorrowDetail{ + {Borrow: domain.Borrow{ID: "borrow-1", CustomerID: "cust-1", BookID: "book-1"}}, + {Borrow: domain.Borrow{ID: "borrow-2", CustomerID: "cust-1", BookID: "book-2"}}, + }, int64(2), nil) + + r := setupPortalBorrowRouter(borrowRepo, bookRepo, customerRepo, "cust-1") + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/borrows", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 2) + borrowRepo.AssertExpectations(t) +} + +func TestPortalBorrowHandler_GetMyBorrows_WithStatusFilter(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + borrowRepo.On("GetAllByCustomer", anyCtx, "cust-1", "active", mock.AnythingOfType("domain.QueryParams")). + Return([]domain.BorrowDetail{ + {Borrow: domain.Borrow{ID: "borrow-1", CustomerID: "cust-1", Status: domain.BorrowStatusActive}}, + }, int64(1), nil) + + r := setupPortalBorrowRouter(borrowRepo, bookRepo, customerRepo, "cust-1") + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/borrows?status=active", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + borrowRepo.AssertExpectations(t) +} + +func TestPortalBorrowHandler_GetMyBorrows_Empty(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + borrowRepo.On("GetAllByCustomer", anyCtx, "cust-1", "", mock.AnythingOfType("domain.QueryParams")). + Return([]domain.BorrowDetail{}, int64(0), nil) + + r := setupPortalBorrowRouter(borrowRepo, bookRepo, customerRepo, "cust-1") + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/borrows", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Empty(t, data) +} + +func TestPortalBorrowHandler_GetMyBorrows_Unauthorized(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + svc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + h := handler.NewPortalBorrowHandler(svc) + + r := helpers.NewRouter() + r.GET("/portal/me/borrows", h.GetMyBorrows) + + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/borrows", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + borrowRepo.AssertNotCalled(t, "GetAllByCustomer") +} diff --git a/internal/handler/portal_customer_handler.go b/internal/handler/portal_customer_handler.go new file mode 100644 index 0000000..5c1f869 --- /dev/null +++ b/internal/handler/portal_customer_handler.go @@ -0,0 +1,89 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +type PortalCustomerHandler struct { + customerSvc *service.CustomerService +} + +func NewPortalCustomerHandler(svc *service.CustomerService) *PortalCustomerHandler { + return &PortalCustomerHandler{customerSvc: svc} +} + +// GetMe godoc +// @Summary Get My Profile +// @Description Returns the authenticated customer's profile. +// @Tags portal/me +// @Produce json +// @Success 200 {object} response.Response{data=domain.SafeCustomer} +// @Router /portal/me [get] +// @Security Bearer +func (h *PortalCustomerHandler) GetMe(c *gin.Context) { + customerID := getCustomerID(c) + if customerID == "" { + return + } + + customer, err := h.customerSvc.GetByID(c.Request.Context(), customerID) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, customer.Safe(), i18n.MsgCustomerFound) +} + +// UpdateMe godoc +// @Summary Update My Profile +// @Description Customers can update their birth date and national code. +// @Tags portal/me +// @Accept json +// @Produce json +// @Param input body domain.UpdateCustomerProfileInput true "Fields to update" +// @Success 200 {object} response.Response{data=domain.SafeCustomer} +// @Router /portal/me [put] +// @Security Bearer +func (h *PortalCustomerHandler) UpdateMe(c *gin.Context) { + customerID := getCustomerID(c) + if customerID == "" { + return + } + + var input domain.UpdateCustomerProfileInput + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, i18n.MsgBadRequest) + return + } + + update := domain.UpdateCustomerProfileInput{ + BirthDate: input.BirthDate, + NationalCode: input.NationalCode, + } + + customer, err := h.customerSvc.UpdateProfile(c.Request.Context(), customerID, update) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, customer.Safe(), i18n.MsgCustomerUpdated) +} + +func getCustomerID(c *gin.Context) string { + val, exists := c.Get("claims") + if !exists { + response.Unauthorized(c) + return "" + } + claims, ok := val.(*domain.Claims) + if !ok || claims.SubjectType != domain.SubjectTypeCustomer { + response.Forbidden(c) + return "" + } + return claims.UserID +} diff --git a/internal/handler/portal_customer_handler_test.go b/internal/handler/portal_customer_handler_test.go new file mode 100644 index 0000000..8248f8a --- /dev/null +++ b/internal/handler/portal_customer_handler_test.go @@ -0,0 +1,126 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func setupPortalCustomerRouter(repo *mocks.MockCustomerRepository, customerID string) *gin.Engine { + svc := service.NewCustomerService(repo) + h := handler.NewPortalCustomerHandler(svc) + + r := helpers.NewRouter() + + withClaims := func(c *gin.Context) { + c.Set("claims", &domain.Claims{ + UserID: customerID, + SubjectType: domain.SubjectTypeCustomer, + }) + c.Next() + } + + r.GET("/portal/me", withClaims, h.GetMe) + r.PUT("/portal/me", withClaims, h.UpdateMe) + return r +} + +func TestPortalCustomerHandler_GetMe_Success(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + repo.On("GetByID", anyCtx, "cust-1").Return(&domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + Active: true, + }, nil) + + r := setupPortalCustomerRouter(repo, "cust-1") + req := helpers.NewRequest(t, http.MethodGet, "/portal/me", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + assert.Equal(t, "cust-1", data["id"]) + assert.Equal(t, "John", data["firstName"]) +} + +func TestPortalCustomerHandler_GetMe_NotFound(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + repo.On("GetByID", anyCtx, "cust-gone"). + Return(nil, customError.NewNotFound(customError.ErrCustomerNotFound, "customer not found")) + + r := setupPortalCustomerRouter(repo, "cust-gone") + req := helpers.NewRequest(t, http.MethodGet, "/portal/me", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestPortalCustomerHandler_GetMe_Unauthorized_NoClaims(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + svc := service.NewCustomerService(repo) + h := handler.NewPortalCustomerHandler(svc) + + r := helpers.NewRouter() + r.GET("/portal/me", h.GetMe) + + req := helpers.NewRequest(t, http.MethodGet, "/portal/me", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + repo.AssertNotCalled(t, "GetByID") +} + +func TestPortalCustomerHandler_GetMe_Forbidden_WrongSubjectType(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + svc := service.NewCustomerService(repo) + h := handler.NewPortalCustomerHandler(svc) + + r := helpers.NewRouter() + r.GET("/portal/me", func(c *gin.Context) { + c.Set("claims", &domain.Claims{ + UserID: "emp-1", + SubjectType: domain.SubjectTypeEmployee, + }) + h.GetMe(c) + }) + + req := helpers.NewRequest(t, http.MethodGet, "/portal/me", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + repo.AssertNotCalled(t, "GetByID") +} + +func TestPortalCustomerHandler_UpdateMe_Success(t *testing.T) { + repo := new(mocks.MockCustomerRepository) + nationalCode := "1234567890" + repo.On("UpdateProfile", anyCtx, "cust-1", anyInput).Return(nil) + repo.On("GetByID", anyCtx, "cust-1").Return(&domain.Customer{ + ID: "cust-1", + FirstName: "John", + NationalCode: &nationalCode, + Active: true, + }, nil) + + r := setupPortalCustomerRouter(repo, "cust-1") + req := helpers.NewRequest(t, http.MethodPut, "/portal/me", map[string]any{ + "nationalCode": "1234567890", + }) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + repo.AssertExpectations(t) +} diff --git a/internal/handler/recommendation_handler.go b/internal/handler/recommendation_handler.go new file mode 100644 index 0000000..f41177a --- /dev/null +++ b/internal/handler/recommendation_handler.go @@ -0,0 +1,122 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +type RecommendationHandler struct { + svc *service.RecommendationService +} + +func NewRecommendationHandler(svc *service.RecommendationService) *RecommendationHandler { + return &RecommendationHandler{svc: svc} +} + +// ItemBasedPortal godoc +// @Summary Book Recommendations (Portal) +// @Description Returns books frequently borrowed alongside this one. No auth required. +// @Tags portal/recommendations +// @Produce json +// @Param id path string true "Book UUID" +// @Success 200 {object} response.Response{data=[]domain.Recommendation} +// @Failure 500 {object} response.Response +// @Router /portal/books/{id}/recommendations [get] +func (h *RecommendationHandler) ItemBasedPortal(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + result, err := h.svc.GetItemBased(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + + recommendations := make([]domain.Recommendation, len(result.Items)) + reasonText := i18n.Translate(c, result.Reason) + for i, item := range result.Items { + recommendations[i] = item.ToRecommendation(reasonText) + } + + msgCode := i18n.MsgRecommendationsFound + if len(recommendations) == 0 { + msgCode = i18n.MsgNoRecommendations + } + + response.OK(c, recommendations, msgCode) +} + +// ItemBasedBackoffice godoc +// @Summary Book Recommendations (Backoffice) +// @Description Returns full book data for recommendations alongside this one. +// @Tags backoffice/recommendations +// @Produce json +// @Param id path string true "Book UUID" +// @Success 200 {object} response.Response{data=[]domain.BackofficeRecommendation} +// @Failure 500 {object} response.Response +// @Router /backoffice/books/{id}/recommendations [get] +// @Security Bearer +func (h *RecommendationHandler) ItemBasedBackoffice(c *gin.Context) { + id, ok := response.ParseUUIDParam(c, "id") + if !ok { + return + } + result, err := h.svc.GetItemBased(c.Request.Context(), id) + if err != nil { + response.HandleAppError(c, err) + return + } + + recommendations := make([]domain.BackofficeRecommendation, len(result.Items)) + for i, item := range result.Items { + recommendations[i] = item.ToBackofficeRecommendation() + } + + msgCode := i18n.MsgRecommendationsFound + if len(recommendations) == 0 { + msgCode = i18n.MsgNoRecommendations + } + + response.OK(c, recommendations, msgCode) +} + +// ProfileBased godoc +// @Summary Personal Recommendations +// @Description Returns books tailored to the authenticated customer's borrowing history. +// @Tags portal/recommendations +// @Produce json +// @Success 200 {object} response.Response{data=[]domain.Recommendation} +// @Failure 401 {object} response.Response +// @Failure 500 {object} response.Response +// @Router /portal/me/recommendations [get] +// @Security Bearer +func (h *RecommendationHandler) ProfileBased(c *gin.Context) { + customerID := getCustomerID(c) + if customerID == "" { + return + } + + result, err := h.svc.GetProfileBased(c.Request.Context(), customerID) + if err != nil { + response.HandleAppError(c, err) + return + } + + recommendations := make([]domain.Recommendation, len(result.Items)) + reasonText := i18n.Translate(c, result.Reason) + for i, item := range result.Items { + recommendations[i] = item.ToRecommendation(reasonText) + } + + msgCode := i18n.MsgRecommendationsFound + if len(recommendations) == 0 { + msgCode = i18n.MsgNoRecommendations + } + + response.OK(c, recommendations, msgCode) +} diff --git a/internal/handler/recommendation_handler_test.go b/internal/handler/recommendation_handler_test.go new file mode 100644 index 0000000..eb995a0 --- /dev/null +++ b/internal/handler/recommendation_handler_test.go @@ -0,0 +1,177 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +const testRecoBookID = "00000000-0000-0000-0000-000000000001" + +func setupRecommendationRouter(repo *mocks.MockRecommendationRepository, t *testing.T) *gin.Engine { + c := helpers.NewTestCache(t) + svc := service.NewRecommendationService(repo, c) + h := handler.NewRecommendationHandler(svc) + + r := helpers.NewRouter() + r.GET("/books/:id/recommendations", h.ItemBasedPortal) + r.GET("/backoffice/books/:id/recommendations", h.ItemBasedBackoffice) + r.GET("/portal/me/recommendations", func(ctx *gin.Context) { + ctx.Set("claims", &domain.Claims{ + UserID: "cust-1", + SubjectType: domain.SubjectTypeCustomer, + }) + h.ProfileBased(ctx) + }) + return r +} + +func TestRecommendationHandler_ItemBasedPortal_WithResults(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + repo.On("GetItemBased", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{ + {Book: domain.Book{ID: "book-2", Title: "Foundation"}, Score: 5}, + {Book: domain.Book{ID: "book-3", Title: "Dune"}, Score: 3}, + }, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/books/"+testRecoBookID+"/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 2) + repo.AssertExpectations(t) +} + +func TestRecommendationHandler_ItemBasedPortal_FallsBackToSameGenre(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + repo.On("GetItemBased", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{}, nil) + repo.On("GetSameGenre", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{ + {Book: domain.Book{ID: "book-4", Title: "Brave New World"}, Score: 1}, + }, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/books/"+testRecoBookID+"/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) + repo.AssertExpectations(t) +} + +func TestRecommendationHandler_ItemBasedPortal_Empty(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + repo.On("GetItemBased", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{}, nil) + repo.On("GetSameGenre", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{}, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/books/"+testRecoBookID+"/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Empty(t, data) +} + +func TestRecommendationHandler_ItemBasedBackoffice_WithResults(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + isbn := "978-0553293357" + repo.On("GetItemBased", anyCtx, testRecoBookID, 10). + Return([]domain.BookWithScore{ + {Book: domain.Book{ID: "book-2", Title: "Foundation", ISBN: &isbn}, Score: 7}, + }, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/backoffice/books/"+testRecoBookID+"/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) + + item := data[0].(map[string]any) + assert.NotNil(t, item["score"]) +} + +func TestRecommendationHandler_ProfileBased_WithResults(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + repo.On("GetProfileBased", anyCtx, "cust-1", 10). + Return([]domain.BookWithScore{ + {Book: domain.Book{ID: "book-5", Title: "Neuromancer"}, Score: 4}, + }, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) +} + +func TestRecommendationHandler_ProfileBased_FallsBackToPopular(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + repo.On("GetProfileBased", anyCtx, "cust-1", 10). + Return([]domain.BookWithScore{}, nil) + repo.On("GetPopular", anyCtx, 10). + Return([]domain.BookWithScore{ + {Book: domain.Book{ID: "book-6", Title: "1984"}, Score: 100}, + }, nil) + + r := setupRecommendationRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) + repo.AssertExpectations(t) +} + +func TestRecommendationHandler_ProfileBased_Unauthorized(t *testing.T) { + repo := new(mocks.MockRecommendationRepository) + c := helpers.NewTestCache(t) + svc := service.NewRecommendationService(repo, c) + h := handler.NewRecommendationHandler(svc) + + r := helpers.NewRouter() + r.GET("/portal/me/recommendations", h.ProfileBased) + + req := helpers.NewRequest(t, http.MethodGet, "/portal/me/recommendations", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + repo.AssertNotCalled(t, "GetProfileBased") +} diff --git a/internal/handler/report_handler.go b/internal/handler/report_handler.go new file mode 100644 index 0000000..1aee040 --- /dev/null +++ b/internal/handler/report_handler.go @@ -0,0 +1,164 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +var ( + _ = domain.BorrowTrend{} + _ = domain.TopBook{} + _ = domain.OverdueBorrow{} + _ = domain.TopCustomer{} + _ = domain.GenrePopularity{} + _ = domain.LowAvailabilityBook{} + _ = domain.MonthlySummary{} +) + +type ReportHandler struct { + svc *service.ReportService +} + +func NewReportHandler(svc *service.ReportService) *ReportHandler { + return &ReportHandler{svc: svc} +} + +// BorrowTrends godoc +// @Summary Borrow Trends +// @Description Borrow counts grouped by day, week, or month over a date range. +// @Tags backoffice/reports +// @Produce json +// @Param period query string false "daily | weekly | monthly (default: monthly)" +// @Param from query string false "Start date YYYY-MM-DD (default: 30 days ago)" +// @Param to query string false "End date YYYY-MM-DD (default: today)" +// @Success 200 {object} response.Response{data=[]domain.BorrowTrend} +// @Router /backoffice/reports/borrows/trends [get] +// @Security Bearer +func (h *ReportHandler) BorrowTrends(c *gin.Context) { + trends, err := h.svc.GetBorrowTrends( + c.Request.Context(), + c.DefaultQuery("period", "monthly"), + c.Query("from"), + c.Query("to"), + ) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, trends, i18n.MsgFetched) +} + +// TopBooks godoc +// @Summary Top Borrowed Books +// @Description Books ranked by total borrow count. +// @Tags backoffice/reports +// @Produce json +// @Param limit query int false "Number of results (default: 10, max: 50)" +// @Success 200 {object} response.Response{data=[]domain.TopBook} +// @Router /backoffice/reports/books/top [get] +// @Security Bearer +func (h *ReportHandler) TopBooks(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + books, err := h.svc.GetTopBooks(c.Request.Context(), limit) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, books, i18n.MsgFetched) +} + +// Overdue godoc +// @Summary Overdue Borrows +// @Description All currently overdue borrows, ordered by most overdue first. +// @Tags backoffice/reports +// @Produce json +// @Success 200 {object} response.Response{data=[]domain.OverdueBorrow} +// @Router /backoffice/reports/borrows/overdue [get] +// @Security Bearer +func (h *ReportHandler) Overdue(c *gin.Context) { + borrows, err := h.svc.GetOverdue(c.Request.Context()) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, borrows, i18n.MsgFetched) +} + +// TopCustomers godoc +// @Summary Most Active Customers +// @Description Customers ranked by total borrow count. +// @Tags backoffice/reports +// @Produce json +// @Param limit query int false "Number of results (default: 10, max: 50)" +// @Success 200 {object} response.Response{data=[]domain.TopCustomer} +// @Router /backoffice/reports/customers/top [get] +// @Security Bearer +func (h *ReportHandler) TopCustomers(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + customers, err := h.svc.GetTopCustomers(c.Request.Context(), limit) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, customers, i18n.MsgFetched) +} + +// GenrePopularity godoc +// @Summary Genre Popularity +// @Description Genres ranked by total borrow count. +// @Tags backoffice/reports +// @Produce json +// @Success 200 {object} response.Response{data=[]domain.GenrePopularity} +// @Router /backoffice/reports/genres/popularity [get] +// @Security Bearer +func (h *ReportHandler) GenrePopularity(c *gin.Context) { + genres, err := h.svc.GetGenrePopularity(c.Request.Context()) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, genres, i18n.MsgFetched) +} + +// LowAvailability godoc +// @Summary Low Availability Books +// @Description Books where available copies are at or below the threshold. +// @Tags backoffice/reports +// @Produce json +// @Param threshold query int false "Available copies threshold (default: 2)" +// @Success 200 {object} response.Response{data=[]domain.LowAvailabilityBook} +// @Router /backoffice/reports/books/low-availability [get] +// @Security Bearer +func (h *ReportHandler) LowAvailability(c *gin.Context) { + threshold, _ := strconv.Atoi(c.DefaultQuery("threshold", "2")) + books, err := h.svc.GetLowAvailability(c.Request.Context(), threshold) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, books, i18n.MsgFetched) +} + +// MonthlySummary godoc +// @Summary Monthly Summary +// @Description Total borrows, returns, overdue, new customers, and new books for a month. +// @Tags backoffice/reports +// @Produce json +// @Param month query string false "Month in YYYY-MM format (default: current month)" +// @Success 200 {object} response.Response{data=domain.MonthlySummary} +// @Router /backoffice/reports/summary/monthly [get] +// @Security Bearer +func (h *ReportHandler) MonthlySummary(c *gin.Context) { + summary, err := h.svc.GetMonthlySummary(c.Request.Context(), c.Query("month")) + if err != nil { + response.HandleAppError(c, err) + return + } + response.OK(c, summary, i18n.MsgFetched) +} diff --git a/internal/handler/report_handler_test.go b/internal/handler/report_handler_test.go new file mode 100644 index 0000000..49cadb2 --- /dev/null +++ b/internal/handler/report_handler_test.go @@ -0,0 +1,222 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/internal/service" + "github.com/mohammad-farrokhnia/library/tests/helpers" + "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +func setupReportRouter(repo *mocks.MockReportRepository, t *testing.T) *gin.Engine { + c := helpers.NewTestCache(t) + svc := service.NewReportService(repo, c) + h := handler.NewReportHandler(svc) + + r := helpers.NewRouter() + r.GET("/reports/borrows/trends", h.BorrowTrends) + r.GET("/reports/books/top", h.TopBooks) + r.GET("/reports/borrows/overdue", h.Overdue) + r.GET("/reports/customers/top", h.TopCustomers) + r.GET("/reports/genres/popularity", h.GenrePopularity) + r.GET("/reports/books/low-availability", h.LowAvailability) + r.GET("/reports/summary/monthly", h.MonthlySummary) + return r +} + +func TestReportHandler_BorrowTrends_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetBorrowTrends", anyCtx, "monthly", anyInput, anyInput). + Return([]domain.BorrowTrend{ + {Period: "2026-01", Count: 42}, + {Period: "2026-02", Count: 38}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/borrows/trends?period=monthly", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 2) +} + +func TestReportHandler_BorrowTrends_DefaultPeriod(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetBorrowTrends", anyCtx, "monthly", anyInput, anyInput). + Return([]domain.BorrowTrend{}, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/borrows/trends", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + repo.AssertExpectations(t) +} + +func TestReportHandler_TopBooks_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetTopBooks", anyCtx, 10). + Return([]domain.TopBook{ + {Book: domain.Book{ID: "book-1", Title: "Dune"}, BorrowCount: 50}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/books/top", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) +} + +func TestReportHandler_TopBooks_CustomLimit(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetTopBooks", anyCtx, 5). + Return([]domain.TopBook{}, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/books/top?limit=5", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + repo.AssertExpectations(t) +} + +func TestReportHandler_Overdue_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("MarkOverdueBorrows", anyCtx).Return(int64(0), nil) + repo.On("GetOverdue", anyCtx). + Return([]domain.OverdueBorrow{ + {BorrowDetail: domain.BorrowDetail{Borrow: domain.Borrow{ID: "borrow-1"}}, DaysOverdue: 3}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/borrows/overdue", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) +} + +func TestReportHandler_TopCustomers_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetTopCustomers", anyCtx, 10). + Return([]domain.TopCustomer{ + {CustomerID: "cust-1", CustomerName: "John Doe", BorrowCount: 20}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/customers/top", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) +} + +func TestReportHandler_GenrePopularity_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetGenrePopularity", anyCtx). + Return([]domain.GenrePopularity{ + {Genre: "Science Fiction", BorrowCount: 120}, + {Genre: "Fantasy", BorrowCount: 95}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/genres/popularity", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 2) +} + +func TestReportHandler_LowAvailability_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetLowAvailability", anyCtx, 2). + Return([]domain.LowAvailabilityBook{ + {Book: domain.Book{ID: "book-1", Title: "Rare Book", AvailableCopies: 1}, AvailablePercent: 10.0}, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/books/low-availability", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Len(t, data, 1) +} + +func TestReportHandler_LowAvailability_CustomThreshold(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetLowAvailability", anyCtx, 5). + Return([]domain.LowAvailabilityBook{}, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/books/low-availability?threshold=5", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + repo.AssertExpectations(t) +} + +func TestReportHandler_MonthlySummary_Success(t *testing.T) { + repo := new(mocks.MockReportRepository) + repo.On("GetMonthlySummary", anyCtx, "2026-05"). + Return(&domain.MonthlySummary{ + Month: "2026-05", + TotalBorrows: 80, + TotalReturns: 70, + NewCustomers: 15, + NewBooks: 5, + }, nil) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/summary/monthly?month=2026-05", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].(map[string]any) + assert.Equal(t, "2026-05", data["month"]) + assert.Equal(t, float64(80), data["totalBorrows"]) +} + +func TestReportHandler_MonthlySummary_InvalidFormat(t *testing.T) { + repo := new(mocks.MockReportRepository) + + r := setupReportRouter(repo, t) + req := helpers.NewRequest(t, http.MethodGet, "/reports/summary/monthly?month=not-a-month", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusUnprocessableEntity, w.Code) + repo.AssertNotCalled(t, "GetMonthlySummary") +} diff --git a/internal/handler/search_handler.go b/internal/handler/search_handler.go new file mode 100644 index 0000000..66e317d --- /dev/null +++ b/internal/handler/search_handler.go @@ -0,0 +1,119 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/search" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +type SearchHandler struct { + querier *search.Querier +} + +func NewSearchHandler(querier *search.Querier) *SearchHandler { + return &SearchHandler{querier: querier} +} + +// PublicSearch godoc +// @Summary Public Book Search +// @Description Full-text search with optional fuzzy matching. Returns limited book info. +// @Tags search +// @Produce json +// @Param q query string false "Search query" +// @Param fuzzy query bool false "Enable fuzzy matching" +// @Param page query int false "Page number" +// @Param size query int false "Page size" +// @Success 200 {object} response.Response +// @Router /search/books [get] +func (h *SearchHandler) PublicSearch(c *gin.Context) { + params := h.buildParams(c) + + result, err := h.querier.SearchBooks(c.Request.Context(), params) + if err != nil { + response.InternalError(c) + return + } + + public := make([]map[string]any, len(result.Documents)) + for i, doc := range result.Documents { + public[i] = map[string]any{ + "id": doc.ID, + "title": doc.Title, + "author": doc.Author, + "genre": doc.Genre, + "description": doc.Description, + "language": doc.Language, + "publisher": doc.Publisher, + "publicationYear": doc.PublicationYear, + "pages": doc.Pages, + "isAvailable": doc.IsAvailable, + } + } + + response.OKWithPagination(c, public, i18n.MsgFetched, result.Total, params.Page, params.Size) +} + +// BackofficeSearch godoc +// @Summary Backoffice Book Search +// @Description Full-text search returning complete book data including ISBN and copy counts. +// @Tags backoffice/search +// @Produce json +// @Param q query string false "Search query" +// @Param fuzzy query bool false "Enable fuzzy matching" +// @Param page query int false "Page number" +// @Param size query int false "Page size" +// @Success 200 {object} response.Response +// @Router /backoffice/search/books [get] +// @Security Bearer +func (h *SearchHandler) BackofficeSearch(c *gin.Context) { + params := h.buildParams(c) + + result, err := h.querier.SearchBooks(c.Request.Context(), params) + if err != nil { + response.InternalError(c) + return + } + + response.OKWithPagination(c, result.Documents, i18n.MsgFetched, result.Total, params.Page, params.Size) +} + +// Suggest godoc +// @Summary Book Autocomplete +// @Description Returns title and author suggestions for a partial query. +// @Tags search +// @Produce json +// @Param q query string true "Partial search term (min 2 chars)" +// @Success 200 {object} response.Response +// @Router /search/books/suggest [get] +func (h *SearchHandler) Suggest(c *gin.Context) { + q := c.Query("q") + if len(q) < 2 { + response.OK(c, []any{}, i18n.MsgFetched) + return + } + + results, err := h.querier.SuggestBooks(c.Request.Context(), q) + if err != nil { + response.InternalError(c) + return + } + + response.OK(c, results, i18n.MsgFetched) +} + +func (h *SearchHandler) buildParams(c *gin.Context) search.Params { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + fuzzy := c.Query("fuzzy") == "true" + + return search.Params{ + Query: c.Query("q"), + Fuzzy: fuzzy, + Page: page, + Size: size, + } +} diff --git a/internal/handler/search_handler_test.go b/internal/handler/search_handler_test.go new file mode 100644 index 0000000..85d0db6 --- /dev/null +++ b/internal/handler/search_handler_test.go @@ -0,0 +1,50 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/mohammad-farrokhnia/library/internal/handler" + "github.com/mohammad-farrokhnia/library/tests/helpers" +) + +func setupSearchRouter() *gin.Engine { + h := handler.NewSearchHandler(nil) + + r := helpers.NewRouter() + r.GET("/search/books", h.PublicSearch) + r.GET("/backoffice/search/books", h.BackofficeSearch) + r.GET("/search/books/suggest", h.Suggest) + return r +} + +func TestSearchHandler_Suggest_ShortQuery_ReturnsEmpty(t *testing.T) { + r := setupSearchRouter() + + req := helpers.NewRequest(t, http.MethodGet, "/search/books/suggest?q=a", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Empty(t, data) +} + +func TestSearchHandler_Suggest_EmptyQuery_ReturnsEmpty(t *testing.T) { + r := setupSearchRouter() + + req := helpers.NewRequest(t, http.MethodGet, "/search/books/suggest", nil) + w := helpers.Do(r, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + helpers.DecodeResponse(t, w, &resp) + data := resp["data"].([]any) + assert.Empty(t, data) +} diff --git a/internal/middleware/auth_midl.go b/internal/middleware/auth_midl.go new file mode 100644 index 0000000..47eb4a4 --- /dev/null +++ b/internal/middleware/auth_midl.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/response" + "github.com/mohammad-farrokhnia/library/pkg/session" +) + +type contextKey string + +const ( + ClaimsKey contextKey = "claims" + EmployeeIDKey contextKey = "employeeId" +) + +func RequireRole(minRoles ...domain.Role) gin.HandlerFunc { + return func(c *gin.Context) { + val, exists := c.Get(string(ClaimsKey)) + if !exists { + response.Unauthorized(c) + return + } + + claims, ok := val.(*domain.Claims) + if !ok { + response.Unauthorized(c) + return + } + + for _, minRole := range minRoles { + if claims.Role.AtLeast(minRole) { + c.Next() + return + } + } + + response.Forbidden(c) + } +} + +func GetClaims(c *gin.Context) (*domain.Claims, bool) { + val, exists := c.Get(string(ClaimsKey)) + if !exists { + return nil, false + } + claims, ok := val.(*domain.Claims) + return claims, ok +} + +func RequireAuth(authSvc *service.AuthService, sessionStore *session.Store, subjectType string) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + response.Unauthorized(c) + return + } + tokenStr := strings.TrimPrefix(header, "Bearer ") + + claims, err := authSvc.ValidateToken(tokenStr) + if err != nil { + response.HandleAppError(c, err) + return + } + + if claims.SubjectType != subjectType { + response.Forbidden(c) + return + } + + exists, err := sessionStore.Exists(c.Request.Context(), claims.ID) + if err != nil || !exists { + response.HandleAppError(c, + customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "session expired or invalidated"), + ) + return + } + + c.Set("claims", claims) + c.Next() + } +} diff --git a/internal/middleware/cors_midl.go b/internal/middleware/cors_midl.go new file mode 100644 index 0000000..8b01264 --- /dev/null +++ b/internal/middleware/cors_midl.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS(allowedOrigins []string) gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowHeaders: []string{ + "Origin", "Content-Type", "Authorization", + "Accept-Language", "X-Request-ID", + }, + ExposeHeaders: []string{"X-RateLimit-Limit", "X-RateLimit-Remaining"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + }) +} diff --git a/internal/middleware/logger_midl.go b/internal/middleware/logger_midl.go new file mode 100644 index 0000000..26ef30c --- /dev/null +++ b/internal/middleware/logger_midl.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "fmt" + "time" + + "github.com/gin-gonic/gin" +) + +var appName string + +func InitLogger(name string) { + appName = name +} + +func Logger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + statusColor := colorForStatus(param.StatusCode) + reset := "\033[0m" + + return fmt.Sprintf("[%s] %s%d%s | %-7s %s | %v | %s\n", + appName, + statusColor, + param.StatusCode, + reset, + param.Method, + param.Path, + param.Latency.Round(time.Microsecond), + param.ClientIP, + ) + }) +} + +func colorForStatus(code int) string { + switch { + case code >= 500: + return "\033[31m" + case code >= 400: + return "\033[33m" + case code >= 300: + return "\033[36m" + default: + return "\033[32m" + } +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..9c135f2 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,206 @@ +package middleware_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/middleware" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func setupRouter() *gin.Engine { + return gin.New() +} + +func TestRequireRole_NoClaims_Unauthorized(t *testing.T) { + r := setupRouter() + r.GET("/test", middleware.RequireRole(domain.RoleSuperAdmin), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestRequireRole_InvalidClaimsType_Unauthorized(t *testing.T) { + r := setupRouter() + r.GET("/test", func(c *gin.Context) { + c.Set("claims", "not-a-claims-struct") + }, middleware.RequireRole(domain.RoleSuperAdmin), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestRequireRole_InsufficientRole_Forbidden(t *testing.T) { + r := setupRouter() + librarianRole := domain.RoleLibrarian + r.GET("/test", func(c *gin.Context) { + c.Set("claims", &domain.Claims{Role: &librarianRole}) + }, middleware.RequireRole(domain.RoleSuperAdmin), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestRequireRole_SufficientRole_Pass(t *testing.T) { + r := setupRouter() + superAdminRole := domain.RoleSuperAdmin + r.GET("/test", func(c *gin.Context) { + c.Set("claims", &domain.Claims{Role: &superAdminRole}) + }, middleware.RequireRole(domain.RoleLibrarian), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRequireRole_ExactRole_Pass(t *testing.T) { + r := setupRouter() + managerRole := domain.RoleManager + r.GET("/test", func(c *gin.Context) { + c.Set("claims", &domain.Claims{Role: &managerRole}) + }, middleware.RequireRole(domain.RoleManager), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRequireRole_MultipleRoles_AnyMatch(t *testing.T) { + r := setupRouter() + librarianRole := domain.RoleLibrarian + r.GET("/test", func(c *gin.Context) { + c.Set("claims", &domain.Claims{Role: &librarianRole}) + }, middleware.RequireRole(domain.RoleSuperAdmin, domain.RoleLibrarian), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestGetClaims_Present(t *testing.T) { + r := setupRouter() + role := domain.RoleManager + claims := &domain.Claims{UserID: "user-1", Role: &role} + + var retrieved *domain.Claims + var ok bool + r.GET("/test", func(c *gin.Context) { + c.Set("claims", claims) + retrieved, ok = middleware.GetClaims(c) + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.True(t, ok) + assert.Equal(t, claims, retrieved) +} + +func TestGetClaims_Missing(t *testing.T) { + r := setupRouter() + + var retrieved *domain.Claims + var ok bool + r.GET("/test", func(c *gin.Context) { + retrieved, ok = middleware.GetClaims(c) + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.False(t, ok) + assert.Nil(t, retrieved) +} + +func TestGetClaims_InvalidType(t *testing.T) { + r := setupRouter() + + var retrieved *domain.Claims + var ok bool + r.GET("/test", func(c *gin.Context) { + c.Set("claims", "invalid") + retrieved, ok = middleware.GetClaims(c) + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.False(t, ok) + assert.Nil(t, retrieved) +} + +func TestRecovery_NoPanic(t *testing.T) { + r := setupRouter() + r.Use(middleware.Recovery()) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRecovery_Panic_Returns500(t *testing.T) { + r := setupRouter() + r.Use(middleware.Recovery()) + r.GET("/test", func(_ *gin.Context) { + panic("something went wrong") + }) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + + var body map[string]string + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + assert.Equal(t, "internal server error", body["error"]) +} diff --git a/internal/middleware/rate_limit_midl.go b/internal/middleware/rate_limit_midl.go new file mode 100644 index 0000000..edd52d9 --- /dev/null +++ b/internal/middleware/rate_limit_midl.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/response" +) + +func RateLimit(rdb *redis.Client, limit int, window time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + key := fmt.Sprintf("rate_limit:%s", c.ClientIP()) + ctx := c.Request.Context() + + count, err := rdb.Incr(ctx, key).Result() + if err != nil { + c.Next() + return + } + + if count == 1 { + rdb.Expire(ctx, key, window) + } + + remaining := limit - int(count) + if remaining < 0 { + remaining = 0 + } + + c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", limit)) + c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining)) + c.Header("X-RateLimit-Window", window.String()) + + if int(count) > limit { + response.Error(c, http.StatusTooManyRequests, i18n.MsgTooManyRequests) + return + } + + c.Next() + } +} diff --git a/internal/middleware/recovery_midl.go b/internal/middleware/recovery_midl.go new file mode 100644 index 0000000..728fe77 --- /dev/null +++ b/internal/middleware/recovery_midl.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + log.Printf("[recovery] panic: %v", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "error": "internal server error", + }) + } + }() + c.Next() + } +} diff --git a/internal/middleware/security_midl.go b/internal/middleware/security_midl.go new file mode 100644 index 0000000..6d04f0a --- /dev/null +++ b/internal/middleware/security_midl.go @@ -0,0 +1,14 @@ +package middleware + +import "github.com/gin-gonic/gin" + +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + c.Next() + } +} diff --git a/internal/middleware/timeout_midl.go b/internal/middleware/timeout_midl.go new file mode 100644 index 0000000..d645e6c --- /dev/null +++ b/internal/middleware/timeout_midl.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" +) + +func Timeout(duration time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), duration) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} diff --git a/internal/repository/book_repo.go b/internal/repository/book_repo.go new file mode 100644 index 0000000..f688521 --- /dev/null +++ b/internal/repository/book_repo.go @@ -0,0 +1,233 @@ +package repository + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/database" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +var bookConstraints = database.ConstraintMap{ + "idx_books_isbn": func() error { + return errors.NewConflict(errors.ErrBookISBNExists, "a book with this ISBN already exists"). + WithContext("isbn", "duplicate") + }, + "idx_books_title": func() error { + return errors.NewConflict(errors.ErrBookAlreadyExists, "a book with this title already exists"). + WithContext("title", "duplicate") + }, + "chk_copies": func() error { + return errors.NewValidation(errors.ErrBookInvalidCopies, "available copies cannot exceed total copies") + }, +} + +type BookRepository interface { + GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) + GetByID(ctx context.Context, id string) (*domain.Book, error) + Create(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) + Update(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) + Delete(ctx context.Context, id string) error + AddCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) + RemoveCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) +} + +type bookRepository struct { + db *sqlx.DB +} + +func NewBookRepository(db *sqlx.DB) BookRepository { + return &bookRepository{db: db} +} + +func (r *bookRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) { + params.Pagination.Validate() + + allowedSortFields := map[string]string{ + "title": "title", + "author": "author", + "genre": "genre", + "publicationYear": "publication_year", + "createdAt": "created_at", + } + params.Sort.Validate(allowedSortFields) + + baseQuery := `SELECT * FROM books` + countQuery := `SELECT COUNT(*) FROM books` + var args []interface{} + var whereClauses []string + + if params.Filter.Search != "" { + whereClauses = append(whereClauses, `(title ILIKE $1 OR author ILIKE $1 OR genre ILIKE $1)`) + args = append(args, "%"+params.Filter.Search+"%") + } + + if len(whereClauses) > 0 { + whereClause := " WHERE " + whereClauses[0] + for i := 1; i < len(whereClauses); i++ { + whereClause += " AND " + whereClauses[i] + } + baseQuery += whereClause + countQuery += whereClause + } + + var total int64 + err := r.db.GetContext(ctx, &total, countQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count books", err) + } + + baseQuery += fmt.Sprintf(" ORDER BY %s %s", params.Sort.Field, params.Sort.Order) + baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2) + args = append(args, params.Pagination.Size, params.Pagination.Offset()) + + books := []domain.Book{} + err = r.db.SelectContext(ctx, &books, baseQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to list books", err) + } + return books, total, nil +} + +func (r *bookRepository) GetByID(ctx context.Context, id string) (*domain.Book, error) { + var book domain.Book + err := r.db.GetContext(ctx, &book, `SELECT * FROM books WHERE id = $1`, id) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrBookNotFound, "book not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get book", err) + } + return &book, nil +} + +func (r *bookRepository) Create(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) { + query := ` + INSERT INTO books (title, author, isbn, genre, publication_year, pages, language, publisher, description, total_copies, available_copies) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) + RETURNING *` + + var book domain.Book + err := r.db.QueryRowxContext(ctx, query, + input.Title, + input.Author, + input.ISBN, + input.Genre, + input.PublicationYear, + input.Pages, + input.Language, + input.Publisher, + input.Description, + input.TotalCopies, + ).StructScan(&book) + if err != nil { + if appErr := database.MapPgError(err, bookConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to create book", err) + } + return &book, nil +} + +func (r *bookRepository) Update(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) { + book, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Genre != nil { + book.Genre = *input.Genre + } + if input.Description != nil { + book.Description = *input.Description + } + if input.Publisher != nil { + book.Publisher = *input.Publisher + } + + query := ` + UPDATE books + SET genre=$1, description=$2, publisher=$3 + WHERE id=$4 + RETURNING *` + + var updated domain.Book + err = r.db.QueryRowxContext(ctx, query, + book.Genre, book.Description, book.Publisher, id, + ).StructScan(&updated) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update book", err) + } + return &updated, nil +} + +func (r *bookRepository) AddCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) { + query := ` + UPDATE books + SET total_copies = total_copies + $1, + available_copies = available_copies + $1 + WHERE id = $2 + RETURNING *` + + var book domain.Book + err := r.db.QueryRowxContext(ctx, query, quantity, id).StructScan(&book) + if err != nil { + if appErr := database.MapPgError(err, bookConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to add copies", err) + } + return &book, nil +} + +func (r *bookRepository) RemoveCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) { + book, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if book.AvailableCopies < quantity { + return nil, errors.NewValidation(errors.ErrBookInsufficient, "insufficient copies available"). + WithContext("available", book.AvailableCopies). + WithContext("requested", quantity) + } + + query := ` + UPDATE books + SET total_copies = total_copies - $1, + available_copies = available_copies - $1 + WHERE id = $2 + RETURNING *` + + var updated domain.Book + err = r.db.QueryRowxContext(ctx, query, quantity, id).StructScan(&updated) + + if err != nil { + if appErr := database.MapPgError(err, bookConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to remove copies", err) + } + return &updated, nil +} + +func (r *bookRepository) Delete(ctx context.Context, id string) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM books WHERE id = $1`, id) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to delete book", err) + } + rows, err := res.RowsAffected() + if rows == 0 { + return errors.NewNotFound(errors.ErrBookDeleteFailed, "book not found") + } + + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to remove copies", err) + } + return nil +} diff --git a/internal/repository/borrow_repo.go b/internal/repository/borrow_repo.go new file mode 100644 index 0000000..2a05a28 --- /dev/null +++ b/internal/repository/borrow_repo.go @@ -0,0 +1,272 @@ +package repository + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/database" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +type BorrowRepository interface { + GetAll(ctx context.Context, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) + GetByID(ctx context.Context, id string) (*domain.BorrowDetail, error) + GetActiveByCustomer(ctx context.Context, customerID string) ([]domain.BorrowDetail, error) + CountActiveByCustomer(ctx context.Context, customerID string) (int, error) + Create(ctx context.Context, input domain.CreateBorrowInput, dueDays int) (*domain.Borrow, error) + Return(ctx context.Context, borrowID string) (*domain.Borrow, error) + GetAllByCustomer(ctx context.Context, customerID string, status string, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) +} + +var borrowConstraints = database.ConstraintMap{ + "idx_borrows_active_unique": func() error { + return errors.NewConflict(errors.ErrAlreadyBorrowed, + "customer already has an active borrow for this book") + }, + "borrows_customer_id_fkey": func() error { + return errors.NewNotFound(errors.ErrCustomerNotFound, "customer not found") + }, + "borrows_book_id_fkey": func() error { + return errors.NewNotFound(errors.ErrBookNotFound, "book not found") + }, +} + +type borrowRepository struct { + db *sqlx.DB +} + +func NewBorrowRepository(db *sqlx.DB) BorrowRepository { + return &borrowRepository{db: db} +} + +const detailSelect = ` + SELECT + b.*, + c.first_name || ' ' || c.last_name AS customer_name, + bk.title AS book_title, + bk.isbn AS book_isbn + FROM borrows b + JOIN customers c ON c.id = b.customer_id + JOIN books bk ON bk.id = b.book_id` + +func (r *borrowRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + params.Pagination.Validate() + + allowedSortFields := map[string]string{ + "borrowedAt": "b.borrowed_at", + "dueDate": "b.due_date", + "status": "b.status", + "createdAt": "b.created_at", + } + params.Sort.Validate(allowedSortFields) + + countQuery := `SELECT COUNT(*) FROM borrows b` + var args []interface{} + var where string + + if params.Filter.Search != "" { + where = ` WHERE (c.first_name ILIKE $1 OR c.last_name ILIKE $1 OR bk.title ILIKE $1)` + args = append(args, "%"+params.Filter.Search+"%") + countQuery += ` JOIN customers c ON c.id = b.customer_id JOIN books bk ON bk.id = b.book_id` + where + } + + var total int64 + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count borrows", err) + } + + query := detailSelect + where + + fmt.Sprintf(" ORDER BY %s %s LIMIT $%d OFFSET $%d", + params.Sort.Field, + params.Sort.Order, + len(args)+1, + len(args)+2, + ) + args = append(args, params.Pagination.Size, params.Pagination.Offset()) + + borrows := []domain.BorrowDetail{} + if err := r.db.SelectContext(ctx, &borrows, query, args...); err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to list borrows", err) + } + return borrows, total, nil +} + +func (r *borrowRepository) GetByID(ctx context.Context, id string) (*domain.BorrowDetail, error) { + var b domain.BorrowDetail + err := r.db.GetContext(ctx, &b, detailSelect+` WHERE b.id = $1`, id) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrBorrowNotFound, "borrow not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get borrow", err) + } + return &b, nil +} + +func (r *borrowRepository) GetActiveByCustomer(ctx context.Context, customerID string) ([]domain.BorrowDetail, error) { + borrows := []domain.BorrowDetail{} + err := r.db.SelectContext(ctx, &borrows, + detailSelect+` WHERE b.customer_id = $1 AND b.status = 'active' ORDER BY b.due_date ASC`, + customerID, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get customer borrows", err) + } + return borrows, nil +} + +func (r *borrowRepository) CountActiveByCustomer(ctx context.Context, customerID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM borrows WHERE customer_id = $1 AND status = 'active'`, + customerID, + ).Scan(&count) + if err != nil { + return 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count active borrows", err) + } + return count, nil +} + +func (r *borrowRepository) Create(ctx context.Context, input domain.CreateBorrowInput, dueDays int) (*domain.Borrow, error) { + var borrow domain.Borrow + + err := r.withTx(ctx, func(tx *sqlx.Tx) error { + dueDate := time.Now().AddDate(0, 0, dueDays) + + err := tx.QueryRowxContext(ctx, ` + INSERT INTO borrows (customer_id, book_id, due_date) + VALUES ($1, $2, $3) + RETURNING *`, + input.CustomerID, input.BookID, dueDate, + ).StructScan(&borrow) + if err != nil { + if appErr := database.MapPgError(err, borrowConstraints); appErr != nil { + return appErr + } + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to insert borrow", err) + } + + res, err := tx.ExecContext(ctx, ` + UPDATE books SET available_copies = available_copies - 1 + WHERE id = $1 AND available_copies > 0`, + input.BookID, + ) + if err != nil { + if appErr := database.MapPgError(err, borrowConstraints); appErr != nil { + return appErr + } + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to decrement copies", err) + } + if rows, _ := res.RowsAffected(); rows == 0 { + return errors.NewConflict(errors.ErrBookUnavailableToBorrow, "book has no available copies") + } + return nil + }) + + if err != nil { + return nil, err + } + return &borrow, nil +} + +func (r *borrowRepository) Return(ctx context.Context, borrowID string) (*domain.Borrow, error) { + var borrow domain.Borrow + + err := r.withTx(ctx, func(tx *sqlx.Tx) error { + err := tx.QueryRowxContext(ctx, ` + UPDATE borrows + SET returned_at = NOW(), status = 'returned' + WHERE id = $1 AND status = 'active' + RETURNING *`, + borrowID, + ).StructScan(&borrow) + if stderrors.Is(err, sql.ErrNoRows) { + return errors.NewNotFound(errors.ErrBorrowNotFound, "active borrow not found") + } + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to mark borrow returned", err) + } + + _, err = tx.ExecContext(ctx, ` + UPDATE books SET available_copies = available_copies + 1 WHERE id = $1`, + borrow.BookID, + ) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to increment copies", err) + } + + return nil + }) + + if err != nil { + return nil, err + } + return &borrow, nil +} + +func (r *borrowRepository) withTx(ctx context.Context, fn func(*sqlx.Tx) error) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to begin transaction", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() + panic(p) + } + }() + + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + + if err := tx.Commit(); err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to commit transaction", err) + } + return nil +} + +func (r *borrowRepository) GetAllByCustomer(ctx context.Context, customerID, status string, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + params.Pagination.Validate() + + allowedSortFields := map[string]string{ + "borrowedAt": "b.borrowed_at", + "dueDate": "b.due_date", + "status": "b.status", + } + params.Sort.Validate(allowedSortFields) + + where := " WHERE b.customer_id = $1" + args := []interface{}{customerID} + + if status != "" { + args = append(args, status) + where += fmt.Sprintf(" AND b.status = $%d", len(args)) + } + + var total int64 + if err := r.db.GetContext(ctx, &total, + "SELECT COUNT(*) FROM borrows b"+where, args...); err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count borrows", err) + } + + args = append(args, params.Pagination.Size, params.Pagination.Offset()) + query := detailSelect + where + + fmt.Sprintf(" ORDER BY %s %s LIMIT $%d OFFSET $%d", + params.Sort.Field, params.Sort.Order, + len(args)-1, len(args)) + + var borrows []domain.BorrowDetail + if err := r.db.SelectContext(ctx, &borrows, query, args...); err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to list customer borrows", err) + } + return borrows, total, nil +} diff --git a/internal/repository/customer_repo.go b/internal/repository/customer_repo.go new file mode 100644 index 0000000..d7faf83 --- /dev/null +++ b/internal/repository/customer_repo.go @@ -0,0 +1,248 @@ +package repository + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/database" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +var customerConstraints = database.ConstraintMap{ + "idx_customers_email": func() error { + return errors.NewConflict(errors.ErrCustomerEmailExists, "a customer with this email already exists"). + WithContext("email", "duplicate") + }, + "idx_customers_mobile": func() error { + return errors.NewConflict(errors.ErrCustomerMobileExists, "a customer with this mobile already exists"). + WithContext("mobile", "duplicate") + }, + "idx_customers_national_code": func() error { + return errors.NewConflict(errors.ErrCustomerNationalCodeExists, "a customer with this national code already exists"). + WithContext("nationalCode", "duplicate") + }, +} + +type CustomerRepository interface { + GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) + GetByID(ctx context.Context, id string) (*domain.Customer, error) + GetByEmail(ctx context.Context, email string) (*domain.Customer, error) + GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) + Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) + Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) + Delete(ctx context.Context, id string) error + GetByGoogleID(ctx context.Context, googleID string) (*domain.Customer, error) + LinkGoogleID(ctx context.Context, customerID string, googleID string) error + UpdatePassword(ctx context.Context, id string, password string) error + UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error +} + +type customerRepository struct { + db *sqlx.DB +} + +func NewCustomerRepository(db *sqlx.DB) CustomerRepository { + return &customerRepository{db: db} +} + +func (r *customerRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) { + params.Pagination.Validate() + + allowedSortFields := map[string]string{ + "firstName": "first_name", + "lastName": "last_name", + "email": "email", + "mobile": "mobile", + "createdAt": "created_at", + } + params.Sort.Validate(allowedSortFields) + + baseQuery := `SELECT * FROM customers` + countQuery := `SELECT COUNT(*) FROM customers` + var args []interface{} + var whereClauses []string + + if params.Filter.Search != "" { + whereClauses = append(whereClauses, `(first_name ILIKE $1 OR last_name ILIKE $1 OR email ILIKE $1 OR mobile ILIKE $1)`) + args = append(args, "%"+params.Filter.Search+"%") + } + + if len(whereClauses) > 0 { + whereClause := " WHERE " + whereClauses[0] + for i := 1; i < len(whereClauses); i++ { + whereClause += " AND " + whereClauses[i] + } + baseQuery += whereClause + countQuery += whereClause + } + + var total int64 + err := r.db.GetContext(ctx, &total, countQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count customers", err) + } + + baseQuery += fmt.Sprintf(" ORDER BY %s %s", params.Sort.Field, params.Sort.Order) + baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2) + args = append(args, params.Pagination.Size, params.Pagination.Offset()) + + customers := []domain.Customer{} + err = r.db.SelectContext(ctx, &customers, baseQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to list customers", err) + } + return customers, total, nil +} + +func (r *customerRepository) GetByID(ctx context.Context, id string) (*domain.Customer, error) { + var c domain.Customer + err := r.db.GetContext(ctx, &c, `SELECT * FROM customers WHERE id = $1`, id) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrCustomerNotFound, "customer not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get customer", err) + } + return &c, nil +} + +func (r *customerRepository) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { + var c domain.Customer + err := r.db.GetContext(ctx, &c, `SELECT * FROM customers WHERE email = $1`, email) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrCustomerNotFound, "customer not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get customer by email", err) + } + return &c, nil +} + +func (r *customerRepository) GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) { + var c domain.Customer + err := r.db.GetContext(ctx, &c, `SELECT * FROM customers WHERE mobile = $1`, mobile) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrCustomerNotFound, "customer not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get customer by mobile", err) + } + return &c, nil +} + +func (r *customerRepository) Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) { + query := ` + INSERT INTO customers (first_name, last_name, email, email_showcase, mobile, address, national_code, birth_date, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *` + + var c domain.Customer + err := r.db.QueryRowxContext(ctx, query, + input.FirstName, + input.LastName, + input.Email, + input.EmailShowcase, + input.Mobile, + input.Address, + input.NationalCode, + input.BirthDate, + input.Password, + ).StructScan(&c) + if err != nil { + if appErr := database.MapPgError(err, customerConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to create customer", err) + } + return &c, nil +} + +func (r *customerRepository) Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) { + customer, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Address != nil { + customer.Address = input.Address + } + if input.Active != nil { + customer.Active = *input.Active + } + + query := ` + UPDATE customers + SET address=$1, active=$2 + WHERE id=$3 + RETURNING *` + + var updated domain.Customer + err = r.db.QueryRowxContext(ctx, query, + customer.Address, + customer.Active, + id, + ).StructScan(&updated) + if err != nil { + if appErr := database.MapPgError(err, customerConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update customer", err) + } + return &updated, nil +} + +func (r *customerRepository) Delete(ctx context.Context, id string) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM customers WHERE id = $1`, id) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to delete customer", err) + } + rows, _ := res.RowsAffected() + if rows == 0 { + return errors.NewNotFound(errors.ErrCustomerDeleteFailed, "customer not found") + } + return nil +} + +func (r *customerRepository) GetByGoogleID(ctx context.Context, googleID string) (*domain.Customer, error) { + var c domain.Customer + err := r.db.GetContext(ctx, &c, `SELECT * FROM customers WHERE google_id = $1`, googleID) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrCustomerNotFound, "customer not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get customer by google id", err) + } + return &c, nil +} + +func (r *customerRepository) LinkGoogleID(ctx context.Context, customerID string, googleID string) error { + _, err := r.db.ExecContext(ctx, `UPDATE customers SET google_id = $1 WHERE id = $2`, googleID, customerID) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to link google id", err) + } + return nil +} +func (r *customerRepository) UpdatePassword(ctx context.Context, id string, password string) error { + _, err := r.db.ExecContext(ctx, `UPDATE customers SET password = $1 WHERE id = $2`, password, id) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update password", err) + } + return nil +} + +func (r *customerRepository) UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error { + query := `UPDATE customers SET birth_date = $1, national_code = $2 WHERE id = $3` + _, err := r.db.ExecContext(ctx, query, input.BirthDate, input.NationalCode, id) + if err != nil { + if appErr := database.MapPgError(err, customerConstraints); appErr != nil { + return appErr + } + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update customer profile", err) + } + return nil +} diff --git a/internal/repository/employee_repo.go b/internal/repository/employee_repo.go new file mode 100644 index 0000000..b12313e --- /dev/null +++ b/internal/repository/employee_repo.go @@ -0,0 +1,237 @@ +package repository + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "log" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/database" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +var employeeConstraints = database.ConstraintMap{ + "idx_employees_email": func() error { + return errors.NewConflict(errors.ErrEmployeeEmailExists, "an employee with this email already exists"). + WithContext("email", "duplicate") + }, + "idx_employees_mobile": func() error { + return errors.NewConflict(errors.ErrEmployeeMobileExists, "an employee with this mobile already exists"). + WithContext("mobile", "duplicate") + }, + "idx_employees_national_code": func() error { + return errors.NewConflict(errors.ErrEmployeeNationalCodeExists, "an employee with this national code already exists"). + WithContext("nationalCode", "duplicate") + }, +} + +type EmployeeRepository interface { + Create(ctx context.Context, input domain.CreateEmployeeInput, hashedPassword string) (*domain.Employee, error) + GetByID(ctx context.Context, id string) (*domain.Employee, error) + GetByEmail(ctx context.Context, email string) (*domain.Employee, error) + GetByMobile(ctx context.Context, mobile string) (*domain.Employee, error) + GetByNationalCode(ctx context.Context, nationalCode string) (*domain.Employee, error) + Update(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) + Delete(ctx context.Context, id string) error + List(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) + UpdatePassword(ctx context.Context, id string, password string) error +} + +type employeeRepository struct { + db *sqlx.DB +} + +func NewEmployeeRepository(db *sqlx.DB) EmployeeRepository { + return &employeeRepository{db: db} +} + +func (r *employeeRepository) List(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) { + params.Pagination.Validate() + + allowedSortFields := map[string]string{ + "firstName": "first_name", + "lastName": "last_name", + "email": "email", + "role": "role", + "createdAt": "created_at", + } + params.Sort.Validate(allowedSortFields) + + baseQuery := `SELECT * FROM employees` + countQuery := `SELECT COUNT(*) FROM employees` + var args []interface{} + var whereClauses []string + + if params.Filter.Search != "" { + whereClauses = append(whereClauses, `(first_name ILIKE $1 OR last_name ILIKE $1 OR email ILIKE $1)`) + args = append(args, "%"+params.Filter.Search+"%") + } + + if len(whereClauses) > 0 { + whereClause := " WHERE " + whereClauses[0] + for i := 1; i < len(whereClauses); i++ { + whereClause += " AND " + whereClauses[i] + } + baseQuery += whereClause + countQuery += whereClause + } + + var total int64 + err := r.db.GetContext(ctx, &total, countQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to count employees", err) + } + + baseQuery += fmt.Sprintf(" ORDER BY %s %s", params.Sort.Field, params.Sort.Order) + baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2) + args = append(args, params.Pagination.Size, params.Pagination.Offset()) + + employees := []domain.Employee{} + err = r.db.SelectContext(ctx, &employees, baseQuery, args...) + if err != nil { + return nil, 0, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to list employees", err) + } + return employees, total, nil +} + +func (r *employeeRepository) GetByID(ctx context.Context, id string) (*domain.Employee, error) { + var e domain.Employee + err := r.db.GetContext(ctx, &e, `SELECT * FROM employees WHERE id = $1`, id) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrEmployeeNotFound, "employee not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get employee", err) + } + return &e, nil +} + +func (r *employeeRepository) GetByEmail(ctx context.Context, email string) (*domain.Employee, error) { + var e domain.Employee + err := r.db.GetContext(ctx, &e, `SELECT * FROM employees WHERE email = $1`, email) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrEmployeeNotFound, "employee not found") + } + if err != nil { + log.Printf("[DEBUG] GetByEmail scan error for %q: %v", email, err) + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get employee by email", err) + } + return &e, nil +} + +func (r *employeeRepository) GetByMobile(ctx context.Context, mobile string) (*domain.Employee, error) { + var e domain.Employee + err := r.db.GetContext(ctx, &e, `SELECT * FROM employees WHERE mobile = $1`, mobile) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrEmployeeNotFound, "employee not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get employee by mobile", err) + } + return &e, nil +} + +func (r *employeeRepository) GetByNationalCode(ctx context.Context, nationalCode string) (*domain.Employee, error) { + var e domain.Employee + err := r.db.GetContext(ctx, &e, `SELECT * FROM employees WHERE national_code = $1`, nationalCode) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrEmployeeNotFound, "employee not found") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get employee by national code", err) + } + return &e, nil +} + +func (r *employeeRepository) Create(ctx context.Context, input domain.CreateEmployeeInput, hashedPassword string) (*domain.Employee, error) { + query := ` + INSERT INTO employees (first_name, last_name, email, mobile, national_code, birth_date, password, role, email_showcase) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *` + + var e domain.Employee + err := r.db.QueryRowxContext(ctx, query, + input.FirstName, + input.LastName, + input.Email, + input.Mobile, + input.NationalCode, + input.BirthDate, + hashedPassword, + input.Role, + input.EmailShowcase, + ).StructScan(&e) + if err != nil { + if appErr := database.MapPgError(err, employeeConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to create employee", err) + } + return &e, nil +} + +func (r *employeeRepository) Update(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) { + employee, err := r.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Email != nil { + employee.Email = *input.Email + } + if input.Role != nil { + employee.Role = *input.Role + } + if input.Active != nil { + employee.Active = *input.Active + } + + query := ` + UPDATE employees + SET email=$1, role=$2, active=$3 + WHERE id=$4 + RETURNING *` + + var updated domain.Employee + err = r.db.QueryRowxContext(ctx, query, + employee.Email, + employee.Role, + employee.Active, + id, + ).StructScan(&updated) + if err != nil { + if appErr := database.MapPgError(err, employeeConstraints); appErr != nil { + return nil, appErr + } + return nil, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update employee", err) + } + return &updated, nil +} + +func (r *employeeRepository) Delete(ctx context.Context, id string) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM employees WHERE id = $1`, id) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to delete employee", err) + } + rows, _ := res.RowsAffected() + if rows == 0 { + return errors.NewNotFound(errors.ErrEmployeeDeleteFailed, "employee not found") + } + return nil +} + +func (r *employeeRepository) UpdatePassword(ctx context.Context, id string, password string) error { + res, err := r.db.ExecContext(ctx, `UPDATE employees SET password = $1 WHERE id = $2`, password, id) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to update employee password", err) + } + rows, _ := res.RowsAffected() + if rows == 0 { + return errors.NewNotFound(errors.ErrEmployeeUpdateFailed, "employee not found") + } + return nil +} diff --git a/internal/repository/recommendation_repo.go b/internal/repository/recommendation_repo.go new file mode 100644 index 0000000..8425611 --- /dev/null +++ b/internal/repository/recommendation_repo.go @@ -0,0 +1,137 @@ +package repository + +import ( + "context" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +type RecommendationRepository interface { + GetItemBased(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) + GetProfileBased(ctx context.Context, customerID string, limit int) ([]domain.BookWithScore, error) + GetPopular(ctx context.Context, limit int) ([]domain.BookWithScore, error) + GetSameGenre(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) +} + +type recommendationRepository struct { + db *sqlx.DB +} + +func NewRecommendationRepository(db *sqlx.DB) RecommendationRepository { + return &recommendationRepository{db: db} +} + +func (r *recommendationRepository) GetItemBased(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) { + query := ` + SELECT + bk.*, + COUNT(DISTINCT b2.customer_id) AS recommendation_score + FROM borrows b1 + JOIN borrows b2 + ON b2.customer_id = b1.customer_id + AND b2.book_id != b1.book_id + JOIN books bk + ON bk.id = b2.book_id + WHERE b1.book_id = $1 + AND bk.available_copies > 0 + GROUP BY bk.id + ORDER BY recommendation_score DESC + LIMIT $2` + + var results []domain.BookWithScore + if err := r.db.SelectContext(ctx, &results, query, bookID, limit); err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "item-based recommendation failed", err) + } + return results, nil +} + +func (r *recommendationRepository) GetProfileBased(ctx context.Context, customerID string, limit int) ([]domain.BookWithScore, error) { + query := ` + WITH customer_genres AS ( + SELECT bk.genre, COUNT(*) AS cnt + FROM borrows br + JOIN books bk ON bk.id = br.book_id + WHERE br.customer_id = $1 + AND bk.genre IS NOT NULL + AND bk.genre != '' + GROUP BY bk.genre + ORDER BY cnt DESC + LIMIT 3 + ), + customer_authors AS ( + SELECT bk.author, COUNT(*) AS cnt + FROM borrows br + JOIN books bk ON bk.id = br.book_id + WHERE br.customer_id = $1 + GROUP BY bk.author + ORDER BY cnt DESC + LIMIT 3 + ), + borrowed_books AS ( + SELECT book_id FROM borrows WHERE customer_id = $1 + ) + SELECT + bk.*, + ( + CASE WHEN bk.genre IN (SELECT genre FROM customer_genres) THEN 2 ELSE 0 END + + CASE WHEN bk.author IN (SELECT author FROM customer_authors) THEN 1 ELSE 0 END + ) AS recommendation_score + FROM books bk + WHERE bk.id NOT IN (SELECT book_id FROM borrowed_books) + AND bk.available_copies > 0 + AND ( + bk.genre IN (SELECT genre FROM customer_genres) + OR bk.author IN (SELECT author FROM customer_authors) + ) + ORDER BY recommendation_score DESC, bk.available_copies DESC + LIMIT $2` + + var results []domain.BookWithScore + if err := r.db.SelectContext(ctx, &results, query, customerID, limit); err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "profile-based recommendation failed", err) + } + return results, nil +} + +func (r *recommendationRepository) GetPopular(ctx context.Context, limit int) ([]domain.BookWithScore, error) { + query := ` + SELECT + bk.*, + COUNT(br.id) AS recommendation_score + FROM books bk + LEFT JOIN borrows br ON br.book_id = bk.id + WHERE bk.available_copies > 0 + GROUP BY bk.id + ORDER BY recommendation_score DESC, bk.available_copies DESC + LIMIT $1` + + var results []domain.BookWithScore + if err := r.db.SelectContext(ctx, &results, query, limit); err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "popular books query failed", err) + } + return results, nil +} + +func (r *recommendationRepository) GetSameGenre(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) { + query := ` + SELECT + bk.*, + COUNT(br.id) AS recommendation_score + FROM books bk + LEFT JOIN borrows br ON br.book_id = bk.id + WHERE bk.genre = (SELECT genre FROM books WHERE id = $1) + AND bk.id != $1 + AND bk.available_copies > 0 + GROUP BY bk.id + ORDER BY recommendation_score DESC + LIMIT $2` + + var results []domain.BookWithScore + if err := r.db.SelectContext(ctx, &results, query, bookID, limit); err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "same genre query failed", err) + } + return results, nil +} diff --git a/internal/repository/report_repo.go b/internal/repository/report_repo.go new file mode 100644 index 0000000..28a27de --- /dev/null +++ b/internal/repository/report_repo.go @@ -0,0 +1,223 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +type ReportRepository interface { + GetBorrowTrends(ctx context.Context, period, from, to string) ([]domain.BorrowTrend, error) + GetTopBooks(ctx context.Context, limit int) ([]domain.TopBook, error) + GetOverdue(ctx context.Context) ([]domain.OverdueBorrow, error) + GetTopCustomers(ctx context.Context, limit int) ([]domain.TopCustomer, error) + GetGenrePopularity(ctx context.Context) ([]domain.GenrePopularity, error) + GetLowAvailability(ctx context.Context, threshold int) ([]domain.LowAvailabilityBook, error) + GetMonthlySummary(ctx context.Context, month string) (*domain.MonthlySummary, error) + MarkOverdueBorrows(ctx context.Context) (int64, error) +} + +type reportRepository struct { + db *sqlx.DB +} + +func NewReportRepository(db *sqlx.DB) ReportRepository { + return &reportRepository{db: db} +} + +func (r *reportRepository) GetBorrowTrends(ctx context.Context, period, from, to string) ([]domain.BorrowTrend, error) { + truncFormat, displayFormat, err := trendFormats(period) + if err != nil { + return nil, errors.NewValidation(errors.ErrValidationInvalid, err.Error()) + } + + query := fmt.Sprintf(` + SELECT + TO_CHAR(DATE_TRUNC('%s', borrowed_at), '%s') AS period, + COUNT(*) AS count + FROM borrows + WHERE borrowed_at BETWEEN $1 AND $2 + GROUP BY DATE_TRUNC('%s', borrowed_at) + ORDER BY DATE_TRUNC('%s', borrowed_at)`, + truncFormat, displayFormat, truncFormat, truncFormat, + ) + + var trends []domain.BorrowTrend + if err := r.db.SelectContext(ctx, &trends, query, from, to); err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get borrow trends", err) + } + return trends, nil +} + +func trendFormats(period string) (truncFormat, displayFormat string, err error) { + switch period { + case "daily": + return "day", "YYYY-MM-DD", nil + case "weekly": + return "week", "YYYY-MM-DD", nil + case "monthly": + return "month", "YYYY-MM", nil + default: + return "", "", fmt.Errorf("period must be 'daily', 'weekly', or 'monthly'") + } +} + +func (r *reportRepository) GetTopBooks(ctx context.Context, limit int) ([]domain.TopBook, error) { + var books []domain.TopBook + err := r.db.SelectContext(ctx, &books, ` + SELECT + bk.*, + COUNT(br.id) AS borrow_count + FROM books bk + JOIN borrows br ON br.book_id = bk.id + GROUP BY bk.id + ORDER BY borrow_count DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get top books", err) + } + return books, nil +} + +func (r *reportRepository) GetOverdue(ctx context.Context) ([]domain.OverdueBorrow, error) { + var borrows []domain.OverdueBorrow + err := r.db.SelectContext(ctx, &borrows, ` + SELECT + br.*, + c.first_name || ' ' || c.last_name AS customer_name, + bk.title AS book_title, + bk.isbn AS book_isbn, + EXTRACT(DAY FROM NOW() - br.due_date)::int AS days_overdue + FROM borrows br + JOIN customers c ON c.id = br.customer_id + JOIN books bk ON bk.id = br.book_id + WHERE br.status = 'active' + AND br.due_date < NOW() + ORDER BY days_overdue DESC`, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get overdue borrows", err) + } + return borrows, nil +} + +func (r *reportRepository) GetTopCustomers(ctx context.Context, limit int) ([]domain.TopCustomer, error) { + var customers []domain.TopCustomer + err := r.db.SelectContext(ctx, &customers, ` + SELECT + c.id AS customer_id, + c.first_name || ' ' || c.last_name AS customer_name, + c.email_showcase AS email, + COUNT(br.id) AS borrow_count + FROM customers c + JOIN borrows br ON br.customer_id = c.id + GROUP BY c.id, c.first_name, c.last_name, c.email_showcase + ORDER BY borrow_count DESC + LIMIT $1`, + limit, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get top customers", err) + } + return customers, nil +} + +func (r *reportRepository) GetGenrePopularity(ctx context.Context) ([]domain.GenrePopularity, error) { + var genres []domain.GenrePopularity + err := r.db.SelectContext(ctx, &genres, ` + SELECT + bk.genre, + COUNT(br.id) AS borrow_count + FROM borrows br + JOIN books bk ON bk.id = br.book_id + WHERE bk.genre IS NOT NULL + AND bk.genre != '' + GROUP BY bk.genre + ORDER BY borrow_count DESC`, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get genre popularity", err) + } + return genres, nil +} + +func (r *reportRepository) GetLowAvailability(ctx context.Context, threshold int) ([]domain.LowAvailabilityBook, error) { + var books []domain.LowAvailabilityBook + err := r.db.SelectContext(ctx, &books, ` + SELECT + *, + ROUND( + available_copies::numeric / NULLIF(total_copies, 0) * 100, + 1) AS available_percent + FROM books + WHERE available_copies <= $1 + AND total_copies > 0 + ORDER BY available_copies ASC, available_percent ASC`, + threshold, + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get low availability books", err) + } + return books, nil +} + +func (r *reportRepository) GetMonthlySummary(ctx context.Context, month string) (*domain.MonthlySummary, error) { + var summary domain.MonthlySummary + err := r.db.GetContext(ctx, &summary, ` + WITH + month_borrows AS ( + SELECT COUNT(*) AS cnt FROM borrows + WHERE DATE_TRUNC('month', borrowed_at) = DATE_TRUNC('month', $1::date) + ), + month_returns AS ( + SELECT COUNT(*) AS cnt FROM borrows + WHERE status = 'returned' + AND DATE_TRUNC('month', returned_at) = DATE_TRUNC('month', $1::date) + ), + month_overdue AS ( + SELECT COUNT(*) AS cnt FROM borrows + WHERE status = 'active' + AND due_date < NOW() + AND DATE_TRUNC('month', due_date) = DATE_TRUNC('month', $1::date) + ), + month_customers AS ( + SELECT COUNT(*) AS cnt FROM customers + WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', $1::date) + ), + month_books AS ( + SELECT COUNT(*) AS cnt FROM books + WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', $1::date) + ) + SELECT + (SELECT cnt FROM month_borrows) AS total_borrows, + (SELECT cnt FROM month_returns) AS total_returns, + (SELECT cnt FROM month_overdue) AS overdue_borrows, + (SELECT cnt FROM month_customers) AS new_customers, + (SELECT cnt FROM month_books) AS new_books`, + month+"-01", + ) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get monthly summary", err) + } + summary.Month = month + return &summary, nil +} + +func (r *reportRepository) MarkOverdueBorrows(ctx context.Context) (int64, error) { + res, err := r.db.ExecContext(ctx, ` + UPDATE borrows + SET status = 'overdue' + WHERE status = 'active' + AND due_date < NOW()`) + if err != nil { + return 0, errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to mark overdue borrows", err) + } + rows, _ := res.RowsAffected() + return rows, nil +} diff --git a/internal/repository/token_repo.go b/internal/repository/token_repo.go new file mode 100644 index 0000000..0068128 --- /dev/null +++ b/internal/repository/token_repo.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + stderrors "errors" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/mohammad-farrokhnia/library/pkg/database" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +var tokenConstraints = database.ConstraintMap{ + "refresh_tokens_token_hash_key": func() error { + return errors.NewConflict(errors.ErrDBConflict, "refresh token already exists") + }, +} + +type RefreshToken struct { + ID string `db:"id"` + UserID string `db:"user_id"` + UserType string `db:"user_type"` + TokenHash string `db:"token_hash"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` +} + +type TokenRepository interface { + CreateRefreshToken(ctx context.Context, userID, userType, tokenHash string, expiresAt time.Time) error + GetRefreshToken(ctx context.Context, tokenHash string) (*RefreshToken, error) + DeleteRefreshToken(ctx context.Context, tokenHash string) error + DeleteAllForUser(ctx context.Context, userID, userType string) error +} + +type tokenRepository struct { + db *sqlx.DB +} + +func NewTokenRepository(db *sqlx.DB) TokenRepository { + return &tokenRepository{db: db} +} + +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (r *tokenRepository) CreateRefreshToken(ctx context.Context, userID, userType, tokenHash string, expiresAt time.Time) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO refresh_tokens (user_id, user_type, token_hash, expires_at) + VALUES ($1, $2, $3, $4)`, + userID, userType, tokenHash, expiresAt, + ) + if err != nil { + if appErr := database.MapPgError(err, tokenConstraints); appErr != nil { + return appErr + } + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to store refresh token", err) + } + return nil +} + +func (r *tokenRepository) GetRefreshToken(ctx context.Context, tokenHash string) (*RefreshToken, error) { + var t RefreshToken + err := r.db.GetContext(ctx, &t, + `SELECT * FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, + tokenHash, + ) + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFound(errors.ErrAuthTokenInvalid, "refresh token not found or expired") + } + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrDBQueryFailed, "failed to get refresh token", err) + } + return &t, nil +} + +func (r *tokenRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM refresh_tokens WHERE token_hash = $1`, tokenHash) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to delete refresh token", err) + } + return nil +} + +func (r *tokenRepository) DeleteAllForUser(ctx context.Context, userID, userType string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM refresh_tokens WHERE user_id = $1 AND user_type = $2`, + userID, userType, + ) + if err != nil { + return errors.NewInternalWithErr(errors.ErrDBExecFailed, "failed to delete user refresh tokens", err) + } + return nil +} diff --git a/internal/search/client.go b/internal/search/client.go new file mode 100644 index 0000000..b82ed5c --- /dev/null +++ b/internal/search/client.go @@ -0,0 +1,40 @@ +package search + +import ( + "fmt" + "log" + + "github.com/elastic/go-elasticsearch/v8" + "github.com/elastic/go-elasticsearch/v8/esapi" +) + +const IndexName = "library_books" + +func NewClient(url string) (*elasticsearch.Client, error) { + cfg := elasticsearch.Config{ + Addresses: []string{url}, + } + client, err := elasticsearch.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create elasticsearch client: %w", err) + } + + res, err := client.Info() + if err != nil { + return nil, fmt.Errorf("elasticsearch not reachable: %w", err) + } + defer closeBody(res) + + if res.IsError() { + return nil, fmt.Errorf("elasticsearch error: %s", res.String()) + } + + return client, nil +} + +func closeBody(res *esapi.Response) { + err := res.Body.Close() + if err != nil { + log.Println(err) + } +} diff --git a/internal/search/client_test.go b/internal/search/client_test.go new file mode 100644 index 0000000..98e89e3 --- /dev/null +++ b/internal/search/client_test.go @@ -0,0 +1,32 @@ +package search_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/search" +) + +func TestNewClient_Success(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestNewClient_InvalidURL(t *testing.T) { + client, err := search.NewClient("invalid-url") + require.Error(t, err) + require.Nil(t, client) + assert.Contains(t, err.Error(), "elasticsearch not reachable") +} + +func TestNewClient_UnreachableHost(t *testing.T) { + client, err := search.NewClient("http://nonexistent-host:9200") + require.Error(t, err) + require.Nil(t, client) + assert.Contains(t, err.Error(), "elasticsearch not reachable") +} diff --git a/internal/search/indexer.go b/internal/search/indexer.go new file mode 100644 index 0000000..03c01ea --- /dev/null +++ b/internal/search/indexer.go @@ -0,0 +1,144 @@ +package search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/elastic/go-elasticsearch/v8" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type BookDocument struct { + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + Genre string `json:"genre"` + Description string `json:"description"` + Language string `json:"language"` + Publisher string `json:"publisher"` + PublicationYear int `json:"publicationYear"` + Pages int `json:"pages"` + ISBN *string `json:"isbn"` + AvailableCopies int `json:"availableCopies"` + TotalCopies int `json:"totalCopies"` + IsAvailable bool `json:"isAvailable"` +} + +const indexMapping = `{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "id": { "type": "keyword" }, + "title": { "type": "search_as_you_type" }, + "author": { "type": "search_as_you_type" }, + "genre": { "type": "keyword" }, + "description": { "type": "text" }, + "language": { "type": "keyword" }, + "publisher": { "type": "text" }, + "publicationYear": { "type": "integer" }, + "pages": { "type": "integer" }, + "isbn": { "type": "keyword" }, + "availableCopies": { "type": "integer" }, + "totalCopies": { "type": "integer" }, + "isAvailable": { "type": "boolean" } + } + } +}` + +type Indexer struct { + client *elasticsearch.Client +} + +func NewIndexer(client *elasticsearch.Client) *Indexer { + return &Indexer{client: client} +} + +func (idx *Indexer) EnsureIndex(ctx context.Context) error { + res, err := idx.client.Indices.Exists([]string{IndexName}, idx.client.Indices.Exists.WithContext(ctx)) + if err != nil { + return fmt.Errorf("check index exists: %w", err) + } + defer closeBody(res) + + if res.StatusCode == 200 { + return nil + } + + res, err = idx.client.Indices.Create( + IndexName, + idx.client.Indices.Create.WithBody(strings.NewReader(indexMapping)), + idx.client.Indices.Create.WithContext(ctx), + ) + if err != nil { + return fmt.Errorf("create index: %w", err) + } + defer closeBody(res) + + if res.IsError() { + return fmt.Errorf("create index error: %s", res.String()) + } + return nil +} + +func (idx *Indexer) IndexBook(ctx context.Context, book *domain.Book) error { + doc := BookDocument{ + ID: book.ID, + Title: book.Title, + Author: book.Author, + Genre: book.Genre, + Description: book.Description, + Language: book.Language, + Publisher: book.Publisher, + PublicationYear: book.PublicationYear, + Pages: book.Pages, + ISBN: book.ISBN, + AvailableCopies: book.AvailableCopies, + TotalCopies: book.TotalCopies, + IsAvailable: book.IsAvailable(), + } + + body, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("marshal book doc: %w", err) + } + + res, err := idx.client.Index( + IndexName, + bytes.NewReader(body), + idx.client.Index.WithDocumentID(book.ID), + idx.client.Index.WithContext(ctx), + ) + if err != nil { + return fmt.Errorf("index book: %w", err) + } + defer closeBody(res) + + if res.IsError() { + return fmt.Errorf("index book error: %s", res.String()) + } + return nil +} + +func (idx *Indexer) DeleteBook(ctx context.Context, id string) error { + res, err := idx.client.Delete( + IndexName, + id, + idx.client.Delete.WithContext(ctx), + ) + if err != nil { + return fmt.Errorf("delete book from index: %w", err) + } + defer closeBody(res) + + if res.IsError() && res.StatusCode != 404 { + return fmt.Errorf("delete book error: %s", res.String()) + } + return nil +} diff --git a/internal/search/indexer_test.go b/internal/search/indexer_test.go new file mode 100644 index 0000000..e63ead5 --- /dev/null +++ b/internal/search/indexer_test.go @@ -0,0 +1,82 @@ +package search_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/search" +) + +func TestNewIndexer(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + indexer := search.NewIndexer(client) + assert.NotNil(t, indexer) +} + +func TestIndexer_EnsureIndex(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + indexer := search.NewIndexer(client) + ctx := context.Background() + + err = indexer.EnsureIndex(ctx) + assert.NoError(t, err) + + err = indexer.EnsureIndex(ctx) + assert.NoError(t, err) +} + +func TestIndexer_IndexBook(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + indexer := search.NewIndexer(client) + ctx := context.Background() + + err = indexer.EnsureIndex(ctx) + require.NoError(t, err) + isbn := "1234567890" + book := &domain.Book{ + ID: "test-book-1", + Title: "Test Book", + Author: "Test Author", + Genre: "Fiction", + Description: "A test book", + Language: "English", + Publisher: "Test Publisher", + PublicationYear: 2024, + Pages: 100, + ISBN: &isbn, + TotalCopies: 5, + AvailableCopies: 3, + } + + err = indexer.IndexBook(ctx, book) + assert.NoError(t, err) +} + +func TestIndexer_DeleteBook(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + indexer := search.NewIndexer(client) + ctx := context.Background() + + err = indexer.DeleteBook(ctx, "non-existent-id") + assert.NoError(t, err) +} diff --git a/internal/search/query.go b/internal/search/query.go new file mode 100644 index 0000000..a458eef --- /dev/null +++ b/internal/search/query.go @@ -0,0 +1,168 @@ +package search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/elastic/go-elasticsearch/v8" +) + +type Params struct { + Query string + Fuzzy bool + Page int + Size int +} + +type Result struct { + Total int64 `json:"total"` + Documents []BookDocument `json:"documents"` +} + +type SuggestResult struct { + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` +} + +type Querier struct { + client *elasticsearch.Client +} + +func NewQuerier(client *elasticsearch.Client) *Querier { + return &Querier{client: client} +} + +func (q *Querier) SearchBooks(ctx context.Context, params Params) (*Result, error) { + if params.Page < 1 { + params.Page = 1 + } + if params.Size < 1 || params.Size > 100 { + params.Size = 10 + } + + fuzziness := "0" + if params.Fuzzy { + fuzziness = "AUTO" + } + + query := map[string]any{ + "from": (params.Page - 1) * params.Size, + "size": params.Size, + "query": map[string]any{ + "multi_match": map[string]any{ + "query": params.Query, + "fields": []string{"title^3", "author^2", "description", "genre"}, + "fuzziness": fuzziness, + "type": "best_fields", + }, + }, + } + + body, _ := json.Marshal(query) + + res, err := q.client.Search( + q.client.Search.WithContext(ctx), + q.client.Search.WithIndex(IndexName), + q.client.Search.WithBody(bytes.NewReader(body)), + q.client.Search.WithTrackTotalHits(true), + ) + if err != nil { + return nil, fmt.Errorf("search books: %w", err) + } + defer closeBody(res) + + if res.IsError() { + return nil, fmt.Errorf("search error: %s", res.String()) + } + + return parseSearchResponse(res.Body) +} + +func (q *Querier) SuggestBooks(ctx context.Context, query string) ([]SuggestResult, error) { + body := map[string]any{ + "size": 8, + "query": map[string]any{ + "multi_match": map[string]any{ + "query": query, + "type": "bool_prefix", + "fields": []string{ + "title", "title._2gram", "title._3gram", + "author", "author._2gram", "author._3gram", + }, + }, + }, + "_source": []string{"id", "title", "author"}, + } + + bodyBytes, _ := json.Marshal(body) + + res, err := q.client.Search( + q.client.Search.WithContext(ctx), + q.client.Search.WithIndex(IndexName), + q.client.Search.WithBody(bytes.NewReader(bodyBytes)), + ) + if err != nil { + return nil, fmt.Errorf("suggest books: %w", err) + } + defer closeBody(res) + + if res.IsError() { + return nil, fmt.Errorf("suggest error: %s", res.String()) + } + + return parseSuggestResponse(res.Body) +} + +func parseSearchResponse(body any) (*Result, error) { + var raw map[string]json.RawMessage + if err := json.NewDecoder(body.(interface{ Read([]byte) (int, error) })).Decode(&raw); err != nil { + return nil, err + } + + var hitsWrapper struct { + Total struct { + Value int64 `json:"value"` + } `json:"total"` + Hits []struct { + Source BookDocument `json:"_source"` + } `json:"hits"` + } + if err := json.Unmarshal(raw["hits"], &hitsWrapper); err != nil { + return nil, err + } + + docs := make([]BookDocument, len(hitsWrapper.Hits)) + for i, h := range hitsWrapper.Hits { + docs[i] = h.Source + } + + return &Result{ + Total: hitsWrapper.Total.Value, + Documents: docs, + }, nil +} + +func parseSuggestResponse(body any) ([]SuggestResult, error) { + var raw map[string]json.RawMessage + if err := json.NewDecoder(body.(interface{ Read([]byte) (int, error) })).Decode(&raw); err != nil { + return nil, err + } + + var hitsWrapper struct { + Hits []struct { + Source SuggestResult `json:"_source"` + } `json:"hits"` + } + if err := json.Unmarshal(raw["hits"], &hitsWrapper); err != nil { + return nil, err + } + + results := make([]SuggestResult, len(hitsWrapper.Hits)) + for i, h := range hitsWrapper.Hits { + results[i] = h.Source + } + return results, nil +} diff --git a/internal/search/query_test.go b/internal/search/query_test.go new file mode 100644 index 0000000..6dee518 --- /dev/null +++ b/internal/search/query_test.go @@ -0,0 +1,130 @@ +package search_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/search" +) + +func TestNewQuerier(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + assert.NotNil(t, querier) +} + +func TestQuerier_SearchBooks_DefaultParams(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + params := search.Params{ + Query: "test", + } + + result, err := querier.SearchBooks(ctx, params) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestQuerier_SearchBooks_WithPagination(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + params := search.Params{ + Query: "test", + Page: 2, + Size: 20, + } + + result, err := querier.SearchBooks(ctx, params) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestQuerier_SearchBooks_WithFuzzy(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + params := search.Params{ + Query: "test", + Fuzzy: true, + } + + result, err := querier.SearchBooks(ctx, params) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestQuerier_SearchBooks_InvalidPage(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + params := search.Params{ + Query: "test", + Page: 0, + } + + result, err := querier.SearchBooks(ctx, params) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestQuerier_SearchBooks_InvalidSize(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + params := search.Params{ + Query: "test", + Size: 150, + } + + result, err := querier.SearchBooks(ctx, params) + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestQuerier_SuggestBooks(t *testing.T) { + t.Skip("requires running Elasticsearch instance") + + client, err := search.NewClient("http://localhost:9200") + require.NoError(t, err) + + querier := search.NewQuerier(client) + ctx := context.Background() + + results, err := querier.SuggestBooks(ctx, "test") + assert.NoError(t, err) + assert.NotNil(t, results) +} diff --git a/internal/service/auth_srv.go b/internal/service/auth_srv.go new file mode 100644 index 0000000..e250823 --- /dev/null +++ b/internal/service/auth_srv.go @@ -0,0 +1,465 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/mailer" + "github.com/mohammad-farrokhnia/library/pkg/otp" + "github.com/mohammad-farrokhnia/library/pkg/session" + "github.com/mohammad-farrokhnia/library/pkg/util" +) + +type PasswordHasher interface { + HashPassword(password string) (string, error) +} + +type GoogleUserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` +} + +type AuthService struct { + employeeRepo repository.EmployeeRepository + customerRepo repository.CustomerRepository + tokenRepo repository.TokenRepository + sessionStore *session.Store + otpStore *otp.Store + mailer mailer.Mailer + jwtSecret []byte + accessExpiry time.Duration + refreshExpiry time.Duration + oauthConfig *oauth2.Config +} + +func NewAuthService( + employeeRepo repository.EmployeeRepository, + customerRepo repository.CustomerRepository, + tokenRepo repository.TokenRepository, + sessionStore *session.Store, + otpStore *otp.Store, + mailer mailer.Mailer, + jwtSecret string, + accessExpiry time.Duration, + refreshExpiry time.Duration, + googleClientID string, + googleClientSecret string, + googleRedirectURL string, +) *AuthService { + return &AuthService{ + employeeRepo: employeeRepo, + customerRepo: customerRepo, + tokenRepo: tokenRepo, + sessionStore: sessionStore, + otpStore: otpStore, + mailer: mailer, + jwtSecret: []byte(jwtSecret), + accessExpiry: accessExpiry, + refreshExpiry: refreshExpiry, + oauthConfig: &oauth2.Config{ + ClientID: googleClientID, + ClientSecret: googleClientSecret, + RedirectURL: googleRedirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + }, + } +} + +func (s *AuthService) LoginEmployee(ctx context.Context, input domain.EmployeeLoginInput) (*domain.TokenPair, *domain.Employee, error) { + var employee *domain.Employee + var err error + + switch input.Handler { + case "email": + stripped := util.StripEmailLocalPartSymbols(input.Email) + employee, err = s.employeeRepo.GetByEmail(ctx, stripped) + case "mobile": + employee, err = s.employeeRepo.GetByMobile(ctx, input.Mobile) + default: + return nil, nil, customErrors.NewValidation(customErrors.ErrBadRequest, "handler must be 'email' or 'mobile'") + } + + if err != nil { + if customErrors.IsNotFound(err) { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "invalid credentials") + } + return nil, nil, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to fetch employee", err) + } + + return s.loginEmployee(ctx, employee, input.Password) +} + +func (s *AuthService) loginEmployee(ctx context.Context, employee *domain.Employee, password string) (*domain.TokenPair, *domain.Employee, error) { + if !employee.Active { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthEmployeeInactive, "employee account is inactive") + } + if err := bcrypt.CompareHashAndPassword([]byte(employee.Password), []byte(password)); err != nil { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "invalid credentials") + } + pair, err := s.generateTokenPair(ctx, employee.ID, domain.SubjectTypeEmployee, &employee.Role) + if err != nil { + return nil, nil, err + } + return pair, employee, nil +} + +func (s *AuthService) SignupCustomer(ctx context.Context, input domain.CustomerSignupInput) (*domain.TokenPair, *domain.Customer, error) { + stripped := util.StripEmailLocalPartSymbols(input.Email) + + if _, err := s.customerRepo.GetByEmail(ctx, stripped); err == nil { + return nil, nil, customErrors.NewConflict(customErrors.ErrCustomerEmailExists, "email already registered").WithContext("email", "duplicate") + } + if _, err := s.customerRepo.GetByMobile(ctx, input.Mobile); err == nil { + return nil, nil, customErrors.NewConflict(customErrors.ErrCustomerMobileExists, "mobile already registered").WithContext("mobile", "duplicate") + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return nil, nil, customErrors.NewInternalWithErr(customErrors.ErrAuthHashFailed, "failed to hash password", err) + } + + customer, err := s.customerRepo.Create(ctx, domain.CreateCustomerInput{ + FirstName: input.FirstName, + LastName: input.LastName, + Email: &stripped, + EmailShowcase: &input.Email, + Mobile: &input.Mobile, + Password: strPtr(string(hashed)), + AuthProvider: "local", + GoogleID: nil, + }) + if err != nil { + return nil, nil, err + } + + _ = s.mailer.SendWelcome(input.Email, input.FirstName) + + pair, err := s.generateTokenPair(ctx, customer.ID, domain.SubjectTypeCustomer, nil) + if err != nil { + return nil, nil, err + } + return pair, customer, nil +} + +func (s *AuthService) LoginCustomer(ctx context.Context, input domain.CustomerLoginInput) (*domain.TokenPair, *domain.Customer, error) { + var customer *domain.Customer + var err error + + switch input.Handler { + case "email": + stripped := util.StripEmailLocalPartSymbols(input.Email) + customer, err = s.customerRepo.GetByEmail(ctx, stripped) + case "mobile": + customer, err = s.customerRepo.GetByMobile(ctx, input.Mobile) + default: + return nil, nil, customErrors.NewValidation(customErrors.ErrBadRequest, "handler must be 'email' or 'mobile'") + } + + if err != nil { + if customErrors.IsNotFound(err) { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "invalid credentials") + } + return nil, nil, err + } + + return s.loginCustomer(ctx, customer, input.Password) +} + +func (s *AuthService) loginCustomer(ctx context.Context, customer *domain.Customer, password string) (*domain.TokenPair, *domain.Customer, error) { + if !customer.Active { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "account is deactivated") + } + if customer.Password == "" { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "please sign in with Google") + } + if err := bcrypt.CompareHashAndPassword([]byte(customer.Password), []byte(password)); err != nil { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "invalid credentials") + } + pair, err := s.generateTokenPair(ctx, customer.ID, domain.SubjectTypeCustomer, nil) + if err != nil { + return nil, nil, err + } + return pair, customer, nil +} + +func (s *AuthService) GoogleAuthURL(state string) string { + return s.oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline) +} + +func (s *AuthService) GoogleAuth(ctx context.Context, code string) (*domain.TokenPair, *domain.Customer, error) { + oauthToken, err := s.oauthConfig.Exchange(ctx, code) + if err != nil { + return nil, nil, customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "failed to exchange Google code") + } + + info, err := s.fetchGoogleUserInfo(ctx, oauthToken) + if err != nil { + return nil, nil, err + } + + stripped := util.StripEmailLocalPartSymbols(info.Email) + + customer, err := s.customerRepo.GetByGoogleID(ctx, info.ID) + if err == nil { + pair, err := s.generateTokenPair(ctx, customer.ID, domain.SubjectTypeCustomer, nil) + return pair, customer, err + } + + customer, err = s.customerRepo.GetByEmail(ctx, stripped) + if err == nil { + if linkErr := s.customerRepo.LinkGoogleID(ctx, customer.ID, info.ID); linkErr != nil { + return nil, nil, linkErr + } + pair, err := s.generateTokenPair(ctx, customer.ID, domain.SubjectTypeCustomer, nil) + return pair, customer, err + } + + customer, err = s.customerRepo.Create(ctx, domain.CreateCustomerInput{ + FirstName: info.FirstName, + LastName: info.LastName, + Email: &stripped, + EmailShowcase: &info.Email, + GoogleID: &info.ID, + AuthProvider: "google", + }) + if err != nil { + return nil, nil, err + } + + _ = s.mailer.SendWelcome(info.Email, info.FirstName) + + pair, err := s.generateTokenPair(ctx, customer.ID, domain.SubjectTypeCustomer, nil) + return pair, customer, err +} + +func (s *AuthService) fetchGoogleUserInfo(ctx context.Context, token *oauth2.Token) (*GoogleUserInfo, error) { + client := s.oauthConfig.Client(ctx, token) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return nil, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to build Google userinfo request", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to fetch Google user info", err) + } + defer closeBody(resp) + + var info GoogleUserInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to decode Google user info", err) + } + return &info, nil +} + +func (s *AuthService) Logout(ctx context.Context, jti, userID, subjectType string) error { + return s.sessionStore.Delete(ctx, jti, userID, subjectType) +} + +func (s *AuthService) Refresh(ctx context.Context, rawToken, subjectType string) (*domain.TokenPair, error) { + hash := repository.HashToken(rawToken) + + stored, err := s.tokenRepo.GetRefreshToken(ctx, hash) + if err != nil { + return nil, customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "invalid or expired refresh token") + } + if stored.UserType != subjectType { + return nil, customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "invalid token type") + } + + role, active, err := s.verifyUserActive(ctx, stored.UserID, subjectType) + if err != nil { + return nil, err + } + if !active { + return nil, customErrors.NewUnauthorized(customErrors.ErrAuthInvalidCredentials, "account is deactivated") + } + + _ = s.tokenRepo.DeleteRefreshToken(ctx, hash) + return s.generateTokenPair(ctx, stored.UserID, subjectType, role) +} + +func (s *AuthService) ForgotPassword(ctx context.Context, email, subjectType string) error { + stripped := util.StripEmailLocalPartSymbols(email) + found := s.userExistsByEmail(ctx, stripped, subjectType) + if !found { + return nil + } + code, err := s.otpStore.Generate(ctx, stripped, subjectType) + if err != nil { + return customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to generate OTP", err) + } + return s.mailer.SendOTP(email, code) +} + +func (s *AuthService) ResetPassword(ctx context.Context, input domain.ResetPasswordInput, subjectType string) error { + stripped := util.StripEmailLocalPartSymbols(input.Email) + + valid, err := s.otpStore.Verify(ctx, stripped, subjectType, input.OTP) + if err != nil || !valid { + return customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "invalid or expired OTP") + } + + userID, err := s.getUserIDByEmail(ctx, stripped, subjectType) + if err != nil { + return err + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) + if err != nil { + return customErrors.NewInternalWithErr(customErrors.ErrAuthHashFailed, "failed to hash password", err) + } + + if err := s.updatePassword(ctx, userID, subjectType, string(hashed)); err != nil { + return err + } + + _ = s.sessionStore.DeleteAllForUser(ctx, userID, subjectType) + _ = s.tokenRepo.DeleteAllForUser(ctx, userID, subjectType) + _ = s.otpStore.Delete(ctx, stripped, subjectType) + return nil +} + +func (s *AuthService) ValidateToken(tokenStr string) (*domain.Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &domain.Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return s.jwtSecret, nil + }) + if err != nil || !token.Valid { + return nil, customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "invalid or expired token") + } + claims, ok := token.Claims.(*domain.Claims) + if !ok { + return nil, customErrors.NewUnauthorized(customErrors.ErrAuthTokenInvalid, "invalid token claims") + } + return claims, nil +} + +func (s *AuthService) HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", customErrors.NewInternalWithErr(customErrors.ErrAuthHashFailed, "failed to hash password", err) + } + return string(bytes), nil +} + +func (s *AuthService) generateTokenPair(ctx context.Context, userID, subjectType string, role *domain.Role) (*domain.TokenPair, error) { + jti := uuid.NewString() + claims := domain.Claims{ + UserID: userID, + SubjectType: subjectType, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + Subject: userID, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessExpiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret) + if err != nil { + return nil, customErrors.NewInternalWithErr(customErrors.ErrAuthTokenFailed, "failed to sign token", err) + } + if err := s.sessionStore.Create(ctx, jti, userID, subjectType, s.accessExpiry); err != nil { + return nil, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "failed to create session", err) + } + rawRefresh := uuid.NewString() + if err := s.tokenRepo.CreateRefreshToken(ctx, userID, subjectType, + repository.HashToken(rawRefresh), + time.Now().Add(s.refreshExpiry), + ); err != nil { + return nil, err + } + return &domain.TokenPair{ + AccessToken: accessToken, + RefreshToken: rawRefresh, + ExpiresIn: int(s.accessExpiry.Seconds()), + TokenType: "Bearer", + }, nil +} + +func (s *AuthService) verifyUserActive(ctx context.Context, userID, subjectType string) (*domain.Role, bool, error) { + switch subjectType { + case domain.SubjectTypeEmployee: + e, err := s.employeeRepo.GetByID(ctx, userID) + if err != nil { + return nil, false, err + } + return &e.Role, e.Active, nil + case domain.SubjectTypeCustomer: + c, err := s.customerRepo.GetByID(ctx, userID) + if err != nil { + return nil, false, err + } + return nil, c.Active, nil + } + return nil, false, customErrors.NewInternalWithErr(customErrors.ErrInternalError, "unknown subject type", nil) +} + +func (s *AuthService) userExistsByEmail(ctx context.Context, email, subjectType string) bool { + switch subjectType { + case domain.SubjectTypeEmployee: + _, err := s.employeeRepo.GetByEmail(ctx, email) + return err == nil + case domain.SubjectTypeCustomer: + _, err := s.customerRepo.GetByEmail(ctx, email) + return err == nil + } + return false +} + +func (s *AuthService) getUserIDByEmail(ctx context.Context, email, subjectType string) (string, error) { + switch subjectType { + case domain.SubjectTypeEmployee: + e, err := s.employeeRepo.GetByEmail(ctx, email) + if err != nil { + return "", err + } + return e.ID, nil + case domain.SubjectTypeCustomer: + c, err := s.customerRepo.GetByEmail(ctx, email) + if err != nil { + return "", err + } + return c.ID, nil + } + return "", customErrors.NewInternalWithErr(customErrors.ErrInternalError, "unknown subject type", nil) +} + +func (s *AuthService) updatePassword(ctx context.Context, userID string, subjectType string, hashed string) error { + switch subjectType { + case domain.SubjectTypeEmployee: + return s.employeeRepo.UpdatePassword(ctx, userID, hashed) + case domain.SubjectTypeCustomer: + return s.customerRepo.UpdatePassword(ctx, userID, hashed) + } + return nil +} + +func strPtr(s string) *string { return &s } + +func closeBody(res *http.Response) { + err := res.Body.Close() + if err != nil { + log.Println(err) + } +} diff --git a/internal/service/auth_srv_test.go b/internal/service/auth_srv_test.go new file mode 100644 index 0000000..7f2f39a --- /dev/null +++ b/internal/service/auth_srv_test.go @@ -0,0 +1,50 @@ +package service_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/service" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +func TestAuthService_HashPassword_Success(t *testing.T) { + auth := service.NewAuthService(nil, nil, nil, nil, nil, nil, "test-secret", 3600, 86400, "", "", "") + + hashed, err := auth.HashPassword("password123") + require.NoError(t, err) + assert.NotEmpty(t, hashed) + assert.NotEqual(t, "password123", hashed) +} + +func TestAuthService_HashPassword_EmptyPassword(t *testing.T) { + auth := service.NewAuthService(nil, nil, nil, nil, nil, nil, "test-secret", 3600, 86400, "", "", "") + + hashed, err := auth.HashPassword("") + require.NoError(t, err) + assert.NotEmpty(t, hashed) +} + +func TestAuthService_ValidateToken_InvalidToken(t *testing.T) { + auth := service.NewAuthService(nil, nil, nil, nil, nil, nil, "test-secret", 3600, 86400, "", "", "") + + _, err := auth.ValidateToken("invalid.token.here") + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrAuthTokenInvalid, appErr.Code()) +} + +func TestAuthService_ValidateToken_EmptyToken(t *testing.T) { + auth := service.NewAuthService(nil, nil, nil, nil, nil, nil, "test-secret", 3600, 86400, "", "", "") + + _, err := auth.ValidateToken("") + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrAuthTokenInvalid, appErr.Code()) +} diff --git a/internal/service/book_srv.go b/internal/service/book_srv.go new file mode 100644 index 0000000..98a5be4 --- /dev/null +++ b/internal/service/book_srv.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/internal/search" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/logger" +) + +type BookService struct { + repo repository.BookRepository + indexer *search.Indexer +} + +func NewBookService(repo repository.BookRepository, indexer *search.Indexer) *BookService { + return &BookService{repo: repo, indexer: indexer} +} + +func (s *BookService) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) { + return s.repo.GetAll(ctx, params) +} + +func (s *BookService) GetByID(ctx context.Context, id string) (*domain.Book, error) { + book, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return book, nil +} + +func (s *BookService) Create(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) { + if input.TotalCopies < 1 { + return nil, customErrors.NewValidation(customErrors.ErrBookInvalidCopies, "total copies must be at least 1"). + WithContext("totalCopies", input.TotalCopies) + } + book, err := s.repo.Create(ctx, input) + if err != nil { + return nil, err + } + s.asyncIndex(book) + return book, nil +} + +func (s *BookService) Update(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) { + book, err := s.repo.Update(ctx, id, input) + if err != nil { + return nil, err + } + s.asyncIndex(book) + return book, nil +} + +func (s *BookService) AddCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) { + book, err := s.repo.AddCopies(ctx, id, quantity) + if err != nil { + return nil, err + } + s.asyncIndex(book) + return book, nil +} + +func (s *BookService) RemoveCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) { + book, err := s.repo.RemoveCopies(ctx, id, quantity) + if err != nil { + return nil, err + } + s.asyncIndex(book) + return book, nil +} + +func (s *BookService) Delete(ctx context.Context, id string) error { + err := s.repo.Delete(ctx, id) + if err != nil { + return err + } + s.asyncDelete(id) + return nil +} + +func (s *BookService) asyncIndex(book *domain.Book) { + if s.indexer == nil { + return + } + go func() { + if err := s.indexer.IndexBook(context.Background(), book); err != nil { + logger.ErrorWithFields("failed to index book", map[string]any{ + "bookId": book.ID, + "error": err.Error(), + }) + } + }() +} + +func (s *BookService) asyncDelete(id string) { + if s.indexer == nil { + return + } + go func() { + if err := s.indexer.DeleteBook(context.Background(), id); err != nil { + logger.ErrorWithFields("failed to delete book from index", map[string]any{ + "bookId": id, + "error": err.Error(), + }) + } + }() +} diff --git a/internal/service/book_srv_test.go b/internal/service/book_srv_test.go new file mode 100644 index 0000000..ed5992a --- /dev/null +++ b/internal/service/book_srv_test.go @@ -0,0 +1,154 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +func TestBookService_Create_InvalidCopies(t *testing.T) { + repo := new(mockBookRepo) + svc := service.NewBookService(repo, nil) + + input := domain.CreateBookInput{ + Title: "Test Book", + Author: "Test Author", + Language: "English", + Publisher: "Test Publisher", + Pages: 100, + TotalCopies: 0, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrBookInvalidCopies, appErr.Code()) +} + +func TestBookService_Create_Success(t *testing.T) { + repo := new(mockBookRepo) + book := &domain.Book{ + ID: "book-1", + Title: "Test Book", + Author: "Test Author", + Language: "English", + } + repo.onCreate = func(_ context.Context, _ domain.CreateBookInput) (*domain.Book, error) { + return book, nil + } + + svc := service.NewBookService(repo, nil) + + input := domain.CreateBookInput{ + Title: "Test Book", + Author: "Test Author", + Language: "English", + Publisher: "Test Publisher", + Pages: 100, + TotalCopies: 5, + } + + result, err := svc.Create(context.Background(), input) + require.NoError(t, err) + assert.Equal(t, book, result) +} + +func TestBookService_GetByID_Success(t *testing.T) { + repo := new(mockBookRepo) + book := &domain.Book{ + ID: "book-1", + Title: "Test Book", + Author: "Test Author", + Language: "English", + } + repo.onGetByID = func(_ context.Context, _ string) (*domain.Book, error) { + return book, nil + } + + svc := service.NewBookService(repo, nil) + + result, err := svc.GetByID(context.Background(), "book-1") + require.NoError(t, err) + assert.Equal(t, book, result) +} + +func TestBookService_GetAll_Success(t *testing.T) { + repo := new(mockBookRepo) + books := []domain.Book{ + {ID: "book-1", Title: "Book 1"}, + {ID: "book-2", Title: "Book 2"}, + } + repo.onGetAll = func(_ context.Context, _ domain.QueryParams) ([]domain.Book, int64, error) { + return books, 2, nil + } + + svc := service.NewBookService(repo, nil) + + result, total, err := svc.GetAll(context.Background(), domain.QueryParams{}) + require.NoError(t, err) + assert.Equal(t, books, result) + assert.Equal(t, int64(2), total) +} + +type mockBookRepo struct { + onCreate func(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) + onGetByID func(ctx context.Context, id string) (*domain.Book, error) + onGetAll func(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) + onUpdate func(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) + onAddCopies func(ctx context.Context, id string, quantity int) (*domain.Book, error) + onDelete func(ctx context.Context, id string) error +} + +func (m *mockBookRepo) Create(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) { + if m.onCreate != nil { + return m.onCreate(ctx, input) + } + return nil, nil +} + +func (m *mockBookRepo) GetByID(ctx context.Context, id string) (*domain.Book, error) { + if m.onGetByID != nil { + return m.onGetByID(ctx, id) + } + return nil, nil +} + +func (m *mockBookRepo) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) { + if m.onGetAll != nil { + return m.onGetAll(ctx, params) + } + return nil, 0, nil +} + +func (m *mockBookRepo) Update(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) { + if m.onUpdate != nil { + return m.onUpdate(ctx, id, input) + } + return nil, nil +} + +func (m *mockBookRepo) AddCopies(ctx context.Context, id string, quantity int) (*domain.Book, error) { + if m.onAddCopies != nil { + return m.onAddCopies(ctx, id, quantity) + } + return nil, nil +} + +func (m *mockBookRepo) RemoveCopies(_ context.Context, _ string, _ int) (*domain.Book, error) { + return nil, nil +} + +func (m *mockBookRepo) Delete(ctx context.Context, id string) error { + if m.onDelete != nil { + return m.onDelete(ctx, id) + } + return nil +} diff --git a/internal/service/borrow_srv.go b/internal/service/borrow_srv.go new file mode 100644 index 0000000..f673ea7 --- /dev/null +++ b/internal/service/borrow_srv.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +const maxActiveBorrows = 3 + +type BorrowService struct { + borrowRepo repository.BorrowRepository + bookRepo repository.BookRepository + customerRepo repository.CustomerRepository +} + +func NewBorrowService( + borrowRepo repository.BorrowRepository, + bookRepo repository.BookRepository, + customerRepo repository.CustomerRepository, +) *BorrowService { + return &BorrowService{ + borrowRepo: borrowRepo, + bookRepo: bookRepo, + customerRepo: customerRepo, + } +} + +func (s *BorrowService) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + return s.borrowRepo.GetAll(ctx, params) +} + +func (s *BorrowService) GetByCustomer(ctx context.Context, customerID string) ([]domain.BorrowDetail, error) { + _, err := s.customerRepo.GetByID(ctx, customerID) + if err != nil { + return nil, err + } + return s.borrowRepo.GetActiveByCustomer(ctx, customerID) +} + +func (s *BorrowService) Borrow(ctx context.Context, input domain.CreateBorrowInput) (*domain.Borrow, error) { + customer, err := s.customerRepo.GetByID(ctx, input.CustomerID) + if err != nil { + return nil, err + } + if !customer.Active { + return nil, errors.NewConflict(errors.ErrCustomerInactive, "customer account is inactive"). + WithContext("customerId", input.CustomerID) + } + + count, err := s.borrowRepo.CountActiveByCustomer(ctx, input.CustomerID) + if err != nil { + return nil, err + } + if count >= maxActiveBorrows { + return nil, errors.NewConflict(errors.ErrBorrowLimitReached, "borrow limit reached"). + WithContext("limit", maxActiveBorrows). + WithContext("current", count) + } + + return s.borrowRepo.Create(ctx, input, domain.DefaultBorrowDays) +} + +func (s *BorrowService) Return(ctx context.Context, borrowID string) (*domain.Borrow, error) { + detail, err := s.borrowRepo.GetByID(ctx, borrowID) + if err != nil { + return nil, err + } + + if detail.Status != domain.BorrowStatusActive { + return nil, errors.NewConflict(errors.ErrBorrowNotActive, "borrow is not active"). + WithContext("borrowId", borrowID). + WithContext("currentStatus", detail.Status) + } + + return s.borrowRepo.Return(ctx, borrowID) +} + +func (s *BorrowService) GetMyBorrows(ctx context.Context, customerID, status string, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + return s.borrowRepo.GetAllByCustomer(ctx, customerID, status, params) +} diff --git a/internal/service/borrow_srv_test.go b/internal/service/borrow_srv_test.go new file mode 100644 index 0000000..0252a00 --- /dev/null +++ b/internal/service/borrow_srv_test.go @@ -0,0 +1,207 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + customError "github.com/mohammad-farrokhnia/library/pkg/errors" + mocks "github.com/mohammad-farrokhnia/library/tests/mocks" +) + +var ( + testCustomerID = "customer-uuid-1" + testBookID = "book-uuid-1" + testBorrowID = "borrow-uuid-1" + + activeCustomer = &domain.Customer{ + ID: testCustomerID, + Active: true, + } + + activeBorrow = &domain.Borrow{ + ID: testBorrowID, + CustomerID: testCustomerID, + BookID: testBookID, + Status: domain.BorrowStatusActive, + DueDate: time.Now().Add(7 * 24 * time.Hour), + } +) + +func newBorrowService( + borrowRepo *mocks.MockBorrowRepository, + bookRepo *mocks.MockBookRepository, + customerRepo *mocks.MockCustomerRepository, +) *service.BorrowService { + return service.NewBorrowService(borrowRepo, bookRepo, customerRepo) +} + +func TestBorrowService_Borrow_Success(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + + input := domain.CreateBorrowInput{ + CustomerID: testCustomerID, + BookID: testBookID, + } + + customerRepo.On("GetByID", ctx, testCustomerID). + Return(activeCustomer, nil) + + borrowRepo.On("CountActiveByCustomer", ctx, testCustomerID). + Return(0, nil) + + borrowRepo.On("Create", ctx, input, domain.DefaultBorrowDays). + Return(activeBorrow, nil) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + + borrow, err := svc.Borrow(ctx, input) + + require.NoError(t, err) + assert.Equal(t, testBorrowID, borrow.ID) + + borrowRepo.AssertExpectations(t) + customerRepo.AssertExpectations(t) +} + +func TestBorrowService_Borrow_CustomerNotFound(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + input := domain.CreateBorrowInput{CustomerID: testCustomerID, BookID: testBookID} + + customerRepo.On("GetByID", ctx, testCustomerID). + Return(nil, customError.NewNotFound(customError.ErrCustomerNotFound, "not found")) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + _, err := svc.Borrow(ctx, input) + + require.Error(t, err) + appErr, ok := err.(customError.AppError) + require.True(t, ok) + assert.Equal(t, customError.ErrorTypeNotFound, appErr.Type()) + bookRepo.AssertNotCalled(t, "GetByID") +} + +func TestBorrowService_Borrow_InactiveCustomer(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + input := domain.CreateBorrowInput{CustomerID: testCustomerID, BookID: testBookID} + + inactiveCustomer := &domain.Customer{ID: testCustomerID, Active: false} + customerRepo.On("GetByID", ctx, testCustomerID).Return(inactiveCustomer, nil) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + _, err := svc.Borrow(ctx, input) + + require.Error(t, err) + bookRepo.AssertNotCalled(t, "GetByID") +} +func TestBorrowService_Borrow_LimitReached(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + + input := domain.CreateBorrowInput{ + CustomerID: testCustomerID, + BookID: testBookID, + } + + customerRepo.On("GetByID", ctx, testCustomerID). + Return(activeCustomer, nil) + + borrowRepo.On("CountActiveByCustomer", ctx, testCustomerID). + Return(3, nil) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + + _, err := svc.Borrow(ctx, input) + + require.Error(t, err) + + appErr, ok := err.(customError.AppError) + require.True(t, ok) + + assert.Equal(t, customError.ErrBorrowLimitReached, appErr.Code()) + + borrowRepo.AssertNotCalled(t, "Create") +} +func TestBorrowService_Return_Success(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + + detail := &domain.BorrowDetail{} + detail.ID = testBorrowID + detail.Status = domain.BorrowStatusActive + + borrowRepo.On("GetByID", ctx, testBorrowID).Return(detail, nil) + borrowRepo.On("Return", ctx, testBorrowID).Return(activeBorrow, nil) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + borrow, err := svc.Return(ctx, testBorrowID) + + require.NoError(t, err) + assert.Equal(t, testBorrowID, borrow.ID) + borrowRepo.AssertExpectations(t) +} + +func TestBorrowService_Return_NotFound(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + + borrowRepo.On("GetByID", ctx, testBorrowID). + Return(nil, customError.NewNotFound(customError.ErrBorrowNotFound, "not found")) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + _, err := svc.Return(ctx, testBorrowID) + + require.Error(t, err) + appErr, ok := err.(customError.AppError) + require.True(t, ok) + assert.Equal(t, customError.ErrorTypeNotFound, appErr.Type()) + borrowRepo.AssertNotCalled(t, "Return") +} + +func TestBorrowService_Return_AlreadyReturned(t *testing.T) { + borrowRepo := new(mocks.MockBorrowRepository) + bookRepo := new(mocks.MockBookRepository) + customerRepo := new(mocks.MockCustomerRepository) + + ctx := context.Background() + + returnedDetail := &domain.BorrowDetail{} + returnedDetail.Status = domain.BorrowStatusReturned + + borrowRepo.On("GetByID", ctx, testBorrowID).Return(returnedDetail, nil) + + svc := newBorrowService(borrowRepo, bookRepo, customerRepo) + _, err := svc.Return(ctx, testBorrowID) + + require.Error(t, err) + appErr, ok := err.(customError.AppError) + require.True(t, ok) + assert.Equal(t, customError.ErrBorrowNotActive, appErr.Code()) + borrowRepo.AssertNotCalled(t, "Return") +} diff --git a/internal/service/customer_srv.go b/internal/service/customer_srv.go new file mode 100644 index 0000000..4179792 --- /dev/null +++ b/internal/service/customer_srv.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/util" + "golang.org/x/crypto/bcrypt" +) + +type CustomerService struct { + repo repository.CustomerRepository +} + +func (s *CustomerService) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) { + return s.repo.GetAll(ctx, params) +} + +func (s *CustomerService) GetByID(ctx context.Context, id string) (*domain.Customer, error) { + customer, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return customer, nil +} + +func (s *CustomerService) GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) { + customer, err := s.repo.GetByMobile(ctx, mobile) + if err != nil { + return nil, err + } + return customer, nil +} + +func (s *CustomerService) Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) { + emailShowcase := input.Email + emailValue := util.StripEmailLocalPartSymbols(*input.Email) + input.Email = &emailValue + + if input.NationalCode == nil { + return nil, errors.NewValidation(errors.ErrValidationRequired, "nationalCode is required") + } + if input.FirstName == "" { + return nil, errors.NewValidation(errors.ErrValidationRequired, "firstName is required") + } + if input.LastName == "" { + return nil, errors.NewValidation(errors.ErrValidationRequired, "lastName is required") + } + + _, err := s.repo.GetByEmail(ctx, *input.Email) + if err == nil { + return nil, errors.NewConflict(errors.ErrCustomerEmailExists, "email already exists"). + WithContext("email", input.Email) + } + if !errors.IsNotFound(err) { + return nil, errors.NewInternalWithErr(errors.ErrInternalError, "failed to check email uniqueness", err) + } + + if input.Mobile != nil { + _, err := s.repo.GetByMobile(ctx, *input.Mobile) + if err == nil { + return nil, errors.NewConflict(errors.ErrCustomerMobileExists, "mobile already exists"). + WithContext("mobile", input.Mobile) + } + if !errors.IsNotFound(err) { + return nil, errors.NewInternalWithErr(errors.ErrInternalError, "failed to check mobile uniqueness", err) + } + } + + defaultPassword := "123456789" + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrCustomerHashFailed, "failed to hash password", err) + } + strPassword := string(hashedPassword) + input.Password = &strPassword + input.EmailShowcase = emailShowcase + return s.repo.Create(ctx, input) +} + +func (s *CustomerService) Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) { + customer, err := s.repo.Update(ctx, id, input) + if err != nil { + return nil, err + } + return customer, nil +} + +func (s *CustomerService) Delete(ctx context.Context, id string) error { + return s.repo.Delete(ctx, id) +} + +func (s *CustomerService) UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) (*domain.Customer, error) { + // TODO: Validate national code via government provider API + // TODO: Validate birth date matches national code records + // TODO: Add age validation (must be 18+ for library membership) + + if err := s.repo.UpdateProfile(ctx, id, input); err != nil { + return nil, err + } + + return s.repo.GetByID(ctx, id) +} + +func NewCustomerService(repo repository.CustomerRepository) *CustomerService { + return &CustomerService{repo: repo} +} diff --git a/internal/service/customer_srv_test.go b/internal/service/customer_srv_test.go new file mode 100644 index 0000000..d3ab076 --- /dev/null +++ b/internal/service/customer_srv_test.go @@ -0,0 +1,283 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +func TestCustomerService_Create_MissingNationalCode(t *testing.T) { + repo := new(mockCustomerRepo) + svc := service.NewCustomerService(repo) + email := "john@example.com" + mobile := "09123456789" + input := domain.CreateCustomerInput{ + FirstName: "John", + LastName: "Doe", + Email: &email, + Mobile: &mobile, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrValidationRequired, appErr.Code()) +} + +func TestCustomerService_Create_MissingFirstName(t *testing.T) { + repo := new(mockCustomerRepo) + svc := service.NewCustomerService(repo) + nationalCode := "1234567890" + email := "john@example.com" + mobile := "09123456789" + input := domain.CreateCustomerInput{ + NationalCode: &nationalCode, + LastName: "Doe", + Email: &email, + Mobile: &mobile, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrValidationRequired, appErr.Code()) +} + +func TestCustomerService_Create_MissingLastName(t *testing.T) { + repo := new(mockCustomerRepo) + svc := service.NewCustomerService(repo) + nationalCode := "1234567890" + email := "john@example.com" + mobile := "09123456789" + input := domain.CreateCustomerInput{ + NationalCode: &nationalCode, + FirstName: "John", + Email: &email, + Mobile: &mobile, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrValidationRequired, appErr.Code()) +} + +func TestCustomerService_Create_EmailExists(t *testing.T) { + repo := new(mockCustomerRepo) + email := "john@example.com" + mobile := "09123456789" + customer := &domain.Customer{ID: "cust-1", Email: &email} + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Customer, error) { + return customer, nil + } + + svc := service.NewCustomerService(repo) + nationalCode := "1234567890" + input := domain.CreateCustomerInput{ + FirstName: "John", + LastName: "Doe", + Email: &email, + NationalCode: &nationalCode, + Mobile: &mobile, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrCustomerEmailExists, appErr.Code()) +} + +func TestCustomerService_Create_MobileExists(t *testing.T) { + repo := new(mockCustomerRepo) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customErrors.NewNotFound(customErrors.ErrCustomerNotFound, "not found") + } + email := "john@example.com" + mobile := "09123456789" + customer := &domain.Customer{ID: "cust-1", Mobile: &mobile} + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Customer, error) { + return customer, nil + } + + svc := service.NewCustomerService(repo) + nationalCode := "1234567890" + input := domain.CreateCustomerInput{ + FirstName: "John", + LastName: "Doe", + Email: &email, + NationalCode: &nationalCode, + Mobile: &mobile, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrCustomerMobileExists, appErr.Code()) +} + +func TestCustomerService_Create_Success(t *testing.T) { + repo := new(mockCustomerRepo) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customErrors.NewNotFound(customErrors.ErrCustomerNotFound, "not found") + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Customer, error) { + return nil, customErrors.NewNotFound(customErrors.ErrCustomerNotFound, "not found") + } + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + } + repo.onCreate = func(_ context.Context, _ domain.CreateCustomerInput) (*domain.Customer, error) { + return customer, nil + } + + svc := service.NewCustomerService(repo) + nationalCode := "1234567890" + email := "1234567890" + mobile := "1234567890" + input := domain.CreateCustomerInput{ + FirstName: "John", + LastName: "Doe", + Email: &email, + NationalCode: &nationalCode, + Mobile: &mobile, + } + + result, err := svc.Create(context.Background(), input) + require.NoError(t, err) + assert.Equal(t, customer, result) +} + +func TestCustomerService_GetByID_Success(t *testing.T) { + repo := new(mockCustomerRepo) + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + } + repo.onGetByID = func(_ context.Context, _ string) (*domain.Customer, error) { + return customer, nil + } + + svc := service.NewCustomerService(repo) + + result, err := svc.GetByID(context.Background(), "cust-1") + require.NoError(t, err) + assert.Equal(t, customer, result) +} + +func TestCustomerService_GetByMobile_Success(t *testing.T) { + repo := new(mockCustomerRepo) + mobile := "1234567890" + customer := &domain.Customer{ + ID: "cust-1", + FirstName: "John", + LastName: "Doe", + Mobile: &mobile, + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Customer, error) { + return customer, nil + } + + svc := service.NewCustomerService(repo) + + result, err := svc.GetByMobile(context.Background(), "09123456789") + require.NoError(t, err) + assert.Equal(t, customer, result) +} + +type mockCustomerRepo struct { + onCreate func(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) + onGetByID func(ctx context.Context, id string) (*domain.Customer, error) + onGetByEmail func(ctx context.Context, email string) (*domain.Customer, error) + onGetByMobile func(ctx context.Context, mobile string) (*domain.Customer, error) + onGetAll func(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) + onUpdate func(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) + onUpdateProfile func(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error + onDelete func(ctx context.Context, id string) error +} + +func (m *mockCustomerRepo) Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) { + if m.onCreate != nil { + return m.onCreate(ctx, input) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByID(ctx context.Context, id string) (*domain.Customer, error) { + if m.onGetByID != nil { + return m.onGetByID(ctx, id) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { + if m.onGetByEmail != nil { + return m.onGetByEmail(ctx, email) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) { + if m.onGetByMobile != nil { + return m.onGetByMobile(ctx, mobile) + } + return nil, nil +} + +func (m *mockCustomerRepo) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) { + if m.onGetAll != nil { + return m.onGetAll(ctx, params) + } + return nil, 0, nil +} + +func (m *mockCustomerRepo) Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) { + if m.onUpdate != nil { + return m.onUpdate(ctx, id, input) + } + return nil, nil +} + +func (m *mockCustomerRepo) UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error { + if m.onUpdateProfile != nil { + return m.onUpdateProfile(ctx, id, input) + } + return nil +} + +func (m *mockCustomerRepo) Delete(ctx context.Context, id string) error { + if m.onDelete != nil { + return m.onDelete(ctx, id) + } + return nil +} + +func (m *mockCustomerRepo) GetByGoogleID(_ context.Context, _ string) (*domain.Customer, error) { + return nil, nil +} + +func (m *mockCustomerRepo) LinkGoogleID(_ context.Context, _ string, _ string) error { + return nil +} + +func (m *mockCustomerRepo) UpdatePassword(_ context.Context, _ string, _ string) error { + return nil +} diff --git a/internal/service/employee_srv.go b/internal/service/employee_srv.go new file mode 100644 index 0000000..460baa3 --- /dev/null +++ b/internal/service/employee_srv.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/util" +) + +type EmployeeService struct { + repo repository.EmployeeRepository + authService PasswordHasher +} + +func NewEmployeeService(repo repository.EmployeeRepository, auth PasswordHasher) *EmployeeService { + return &EmployeeService{repo: repo, authService: auth} +} + +func (s *EmployeeService) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) { + return s.repo.List(ctx, params) +} + +func (s *EmployeeService) GetByID(ctx context.Context, id string) (*domain.Employee, error) { + e, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return e, nil +} + +func (s *EmployeeService) Create(ctx context.Context, input domain.CreateEmployeeInput) (*domain.Employee, error) { + if err := s.validateCreateInput(ctx, input); err != nil { + return nil, err + } + + input = s.prepareCreateInput(input) + hashed, err := s.authService.HashPassword(input.Password) + if err != nil { + return nil, errors.NewInternalWithErr(errors.ErrEmployeeHashFailed, "failed to hash password", err) + } + + return s.repo.Create(ctx, input, hashed) +} + +func (s *EmployeeService) Update(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) { + if err := s.validateUpdateInput(ctx, id, input); err != nil { + return nil, err + } + + e, err := s.repo.Update(ctx, id, input) + if err != nil { + return nil, err + } + return e, nil +} + +func (s *EmployeeService) Delete(ctx context.Context, id string) error { + return s.repo.Delete(ctx, id) +} + +func (s *EmployeeService) validateCreateInput(ctx context.Context, input domain.CreateEmployeeInput) error { + if err := s.checkEmailUniqueness(ctx, input.Email, ""); err != nil { + return err + } + if err := s.checkMobileUniqueness(ctx, input.Mobile, ""); err != nil { + return err + } + if err := s.checkNationalCodeUniqueness(ctx, input.NationalCode, ""); err != nil { + return err + } + return nil +} + +func (s *EmployeeService) validateUpdateInput(ctx context.Context, id string, input domain.UpdateEmployeeInput) error { + if input.Email != nil { + if err := s.checkEmailUniqueness(ctx, *input.Email, id); err != nil { + return err + } + } + if input.Mobile != nil { + if err := s.checkMobileUniqueness(ctx, *input.Mobile, id); err != nil { + return err + } + } + if input.Role != nil && !input.Role.IsValid() { + return errors.NewValidation(errors.ErrEmployeeInvalidRole, "invalid role"). + WithContext("role", *input.Role) + } + return nil +} + +func (s *EmployeeService) checkEmailUniqueness(ctx context.Context, email, excludeID string) error { + strippedEmail := util.StripEmailLocalPartSymbols(email) + existing, err := s.repo.GetByEmail(ctx, strippedEmail) + if err == nil && existing.ID != excludeID { + return errors.NewConflict(errors.ErrEmployeeEmailExists, "email already exists"). + WithContext("email", email) + } + if err != nil && !errors.IsNotFound(err) { + return errors.NewInternalWithErr(errors.ErrInternalError, "failed to check email uniqueness", err) + } + return nil +} + +func (s *EmployeeService) checkMobileUniqueness(ctx context.Context, mobile, excludeID string) error { + existing, err := s.repo.GetByMobile(ctx, mobile) + if err == nil && existing.ID != excludeID { + return errors.NewConflict(errors.ErrEmployeeMobileExists, "mobile already exists"). + WithContext("mobile", mobile) + } + if err != nil && !errors.IsNotFound(err) { + return errors.NewInternalWithErr(errors.ErrInternalError, "failed to check mobile uniqueness", err) + } + return nil +} + +func (s *EmployeeService) checkNationalCodeUniqueness(ctx context.Context, nationalCode, excludeID string) error { + existing, err := s.repo.GetByNationalCode(ctx, nationalCode) + if err == nil && existing.ID != excludeID { + return errors.NewConflict(errors.ErrEmployeeNationalCodeExists, "national code already exists"). + WithContext("nationalCode", nationalCode) + } + if err != nil && !errors.IsNotFound(err) { + return errors.NewInternalWithErr(errors.ErrInternalError, "failed to check national code uniqueness", err) + } + return nil +} + +func (s *EmployeeService) prepareCreateInput(input domain.CreateEmployeeInput) domain.CreateEmployeeInput { + emailShowcase := input.Email + input.Email = util.StripEmailLocalPartSymbols(input.Email) + input.EmailShowcase = emailShowcase + + if !input.Role.IsValid() { + input.Role = domain.RoleLibrarian + } + return input +} diff --git a/internal/service/employee_srv_test.go b/internal/service/employee_srv_test.go new file mode 100644 index 0000000..01b8b38 --- /dev/null +++ b/internal/service/employee_srv_test.go @@ -0,0 +1,317 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/service" + customErrors "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +func TestEmployeeService_Create_EmailExists(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + employee := &domain.Employee{ID: "emp-1", Email: "john@example.com"} + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Employee, error) { + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + input := domain.CreateEmployeeInput{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + NationalCode: "1234567890", + Password: "password123", + Role: domain.RoleLibrarian, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrEmployeeEmailExists, appErr.Code()) +} + +func TestEmployeeService_Create_MobileExists(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + employee := &domain.Employee{ID: "emp-1", Mobile: "09123456789"} + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Employee, error) { + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + input := domain.CreateEmployeeInput{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + NationalCode: "1234567890", + Password: "password123", + Role: domain.RoleLibrarian, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrEmployeeMobileExists, appErr.Code()) +} + +func TestEmployeeService_Create_NationalCodeExists(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + employee := &domain.Employee{ID: "emp-1", NationalCode: "1234567890"} + repo.onGetByNationalCode = func(_ context.Context, _ string) (*domain.Employee, error) { + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + input := domain.CreateEmployeeInput{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + NationalCode: "1234567890", + Password: "password123", + Role: domain.RoleLibrarian, + } + + _, err := svc.Create(context.Background(), input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrEmployeeNationalCodeExists, appErr.Code()) +} + +func TestEmployeeService_Create_Success(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + repo.onGetByNationalCode = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + auth.onHashPassword = func(_ string) (string, error) { + return "hashed_password", nil + } + employee := &domain.Employee{ + ID: "emp-1", + FirstName: "John", + LastName: "Doe", + Role: domain.RoleLibrarian, + } + repo.onCreate = func(_ context.Context, _ domain.CreateEmployeeInput, _ string) (*domain.Employee, error) { + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + input := domain.CreateEmployeeInput{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + NationalCode: "1234567890", + Password: "password123", + Role: domain.RoleLibrarian, + } + + result, err := svc.Create(context.Background(), input) + require.NoError(t, err) + assert.Equal(t, employee, result) +} + +func TestEmployeeService_Create_InvalidRoleDefaultsToLibrarian(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + repo.onGetByEmail = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + repo.onGetByMobile = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + repo.onGetByNationalCode = func(_ context.Context, _ string) (*domain.Employee, error) { + return nil, customErrors.NewNotFound(customErrors.ErrEmployeeNotFound, "not found") + } + auth.onHashPassword = func(_ string) (string, error) { + return "hashed_password", nil + } + + var capturedInput domain.CreateEmployeeInput + employee := &domain.Employee{ + ID: "emp-1", + FirstName: "John", + LastName: "Doe", + Role: domain.RoleLibrarian, + } + repo.onCreate = func(_ context.Context, input domain.CreateEmployeeInput, _ string) (*domain.Employee, error) { + capturedInput = input + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + input := domain.CreateEmployeeInput{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + Mobile: "09123456789", + NationalCode: "1234567890", + Password: "password123", + Role: domain.Role("invalid"), + } + + _, err := svc.Create(context.Background(), input) + require.NoError(t, err) + assert.Equal(t, domain.RoleLibrarian, capturedInput.Role) +} + +func TestEmployeeService_Update_InvalidRole(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + invalidRole := domain.Role("invalid") + svc := service.NewEmployeeService(repo, auth) + + input := domain.UpdateEmployeeInput{ + Role: &invalidRole, + } + + _, err := svc.Update(context.Background(), "emp-1", input) + require.Error(t, err) + + appErr, ok := err.(customErrors.AppError) + require.True(t, ok) + assert.Equal(t, customErrors.ErrEmployeeInvalidRole, appErr.Code()) +} + +func TestEmployeeService_GetByID_Success(t *testing.T) { + repo := new(mockEmployeeRepo) + auth := new(mockPasswordHasher) + employee := &domain.Employee{ + ID: "emp-1", + FirstName: "John", + LastName: "Doe", + Role: domain.RoleLibrarian, + } + repo.onGetByID = func(_ context.Context, _ string) (*domain.Employee, error) { + return employee, nil + } + + svc := service.NewEmployeeService(repo, auth) + + result, err := svc.GetByID(context.Background(), "emp-1") + require.NoError(t, err) + assert.Equal(t, employee, result) +} + +type mockEmployeeRepo struct { + onCreate func(ctx context.Context, input domain.CreateEmployeeInput, hashedPassword string) (*domain.Employee, error) + onGetByID func(ctx context.Context, id string) (*domain.Employee, error) + onGetByEmail func(ctx context.Context, email string) (*domain.Employee, error) + onGetByMobile func(ctx context.Context, mobile string) (*domain.Employee, error) + onGetByNationalCode func(ctx context.Context, nationalCode string) (*domain.Employee, error) + onUpdate func(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) + onDelete func(ctx context.Context, id string) error + onList func(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) + onUpdatePassword func(ctx context.Context, id string, password string) error +} + +func (m *mockEmployeeRepo) Create(ctx context.Context, input domain.CreateEmployeeInput, hashedPassword string) (*domain.Employee, error) { + if m.onCreate != nil { + return m.onCreate(ctx, input, hashedPassword) + } + return nil, nil +} + +func (m *mockEmployeeRepo) GetByID(ctx context.Context, id string) (*domain.Employee, error) { + if m.onGetByID != nil { + return m.onGetByID(ctx, id) + } + return nil, nil +} + +func (m *mockEmployeeRepo) GetByEmail(ctx context.Context, email string) (*domain.Employee, error) { + if m.onGetByEmail != nil { + return m.onGetByEmail(ctx, email) + } + return nil, nil +} + +func (m *mockEmployeeRepo) GetByMobile(ctx context.Context, mobile string) (*domain.Employee, error) { + if m.onGetByMobile != nil { + return m.onGetByMobile(ctx, mobile) + } + return nil, nil +} + +func (m *mockEmployeeRepo) GetByNationalCode(ctx context.Context, nationalCode string) (*domain.Employee, error) { + if m.onGetByNationalCode != nil { + return m.onGetByNationalCode(ctx, nationalCode) + } + return nil, nil +} + +func (m *mockEmployeeRepo) Update(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) { + if m.onUpdate != nil { + return m.onUpdate(ctx, id, input) + } + return nil, nil +} + +func (m *mockEmployeeRepo) Delete(ctx context.Context, id string) error { + if m.onDelete != nil { + return m.onDelete(ctx, id) + } + return nil +} + +func (m *mockEmployeeRepo) List(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) { + if m.onList != nil { + return m.onList(ctx, params) + } + return nil, 0, nil +} + +func (m *mockEmployeeRepo) UpdatePassword(ctx context.Context, id string, password string) error { + if m.onUpdatePassword != nil { + return m.onUpdatePassword(ctx, id, password) + } + return nil +} + +type mockPasswordHasher struct { + onHashPassword func(password string) (string, error) +} + +func (m *mockPasswordHasher) HashPassword(password string) (string, error) { + if m.onHashPassword != nil { + return m.onHashPassword(password) + } + return "", nil +} diff --git a/internal/service/recommendation_srv.go b/internal/service/recommendation_srv.go new file mode 100644 index 0000000..4b62297 --- /dev/null +++ b/internal/service/recommendation_srv.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "time" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/pkg/cache" + "github.com/mohammad-farrokhnia/library/pkg/i18n" +) + +const defaultRecommendationLimit = 10 + +type RecommendationService struct { + repo repository.RecommendationRepository + cache *cache.Cache +} + +func NewRecommendationService(repo repository.RecommendationRepository, c *cache.Cache) *RecommendationService { + return &RecommendationService{repo: repo, cache: c} +} + +const ( + recommendationTTL = 30 * time.Minute +) + +type RecommendationResult struct { + Items []domain.BookWithScore + Reason i18n.MessageCode +} + +func (s *RecommendationService) GetItemBased(ctx context.Context, bookID string) (*RecommendationResult, error) { + return cache.GetOrSet(ctx, s.cache, + cache.Keys().RecommendationItem(bookID), + recommendationTTL, + func() (*RecommendationResult, error) { + items, err := s.repo.GetItemBased(ctx, bookID, defaultRecommendationLimit) + if err != nil { + return nil, err + } + if len(items) > 0 { + return &RecommendationResult{Items: items, Reason: "Customers who borrowed this book also borrowed"}, nil + } + items, err = s.repo.GetSameGenre(ctx, bookID, defaultRecommendationLimit) + if err != nil { + return nil, err + } + return &RecommendationResult{Items: items, Reason: "More books in the same genre"}, nil + }, + ) +} + +func (s *RecommendationService) GetProfileBased(ctx context.Context, customerID string) (*RecommendationResult, error) { + return cache.GetOrSet(ctx, s.cache, + cache.Keys().RecommendationProfile(customerID), + recommendationTTL, + func() (*RecommendationResult, error) { + items, err := s.repo.GetProfileBased(ctx, customerID, defaultRecommendationLimit) + if err != nil { + return nil, err + } + if len(items) > 0 { + return &RecommendationResult{Items: items, Reason: "Based on your borrowing history"}, nil + } + items, err = s.repo.GetPopular(ctx, defaultRecommendationLimit) + if err != nil { + return nil, err + } + return &RecommendationResult{Items: items, Reason: "Most popular in the library"}, nil + }, + ) +} diff --git a/internal/service/report_srv.go b/internal/service/report_srv.go new file mode 100644 index 0000000..bdee733 --- /dev/null +++ b/internal/service/report_srv.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/pkg/cache" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +const ( + overdueTTL = 15 * time.Minute + trendsTTL = 1 * time.Hour + topBooksTTL = 1 * time.Hour + topCustomersTTL = 1 * time.Hour + genreTTL = 1 * time.Hour + lowAvailTTL = 30 * time.Minute + monthlyTTL = 30 * time.Minute +) + +type ReportService struct { + repo repository.ReportRepository + cache *cache.Cache +} + +func NewReportService(repo repository.ReportRepository, c *cache.Cache) *ReportService { + return &ReportService{repo: repo, cache: c} +} + +func (s *ReportService) GetBorrowTrends(ctx context.Context, period, from, to string) ([]domain.BorrowTrend, error) { + if from == "" { + from = time.Now().AddDate(0, 0, -30).Format("2006-01-02") + } + if to == "" { + to = time.Now().Format("2006-01-02") + } + if period == "" { + period = "monthly" + } + + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportBorrowTrends(period, from, to), + trendsTTL, + func() ([]domain.BorrowTrend, error) { + return s.repo.GetBorrowTrends(ctx, period, from, to) + }, + ) +} + +func (s *ReportService) GetTopBooks(ctx context.Context, limit int) ([]domain.TopBook, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportTopBooks(limit), + topBooksTTL, + func() ([]domain.TopBook, error) { + return s.repo.GetTopBooks(ctx, limit) + }, + ) +} + +func (s *ReportService) GetOverdue(ctx context.Context) ([]domain.OverdueBorrow, error) { + marked, err := s.repo.MarkOverdueBorrows(ctx) + if err != nil { + return nil, err + } + + if marked > 0 { + _ = s.cache.Delete(ctx, cache.Keys().ReportOverdue()) + } + + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportOverdue(), + overdueTTL, + func() ([]domain.OverdueBorrow, error) { + return s.repo.GetOverdue(ctx) + }, + ) +} + +func (s *ReportService) GetTopCustomers(ctx context.Context, limit int) ([]domain.TopCustomer, error) { + if limit <= 0 || limit > 50 { + limit = 10 + } + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportTopCustomers(limit), + topCustomersTTL, + func() ([]domain.TopCustomer, error) { + return s.repo.GetTopCustomers(ctx, limit) + }, + ) +} + +func (s *ReportService) GetGenrePopularity(ctx context.Context) ([]domain.GenrePopularity, error) { + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportGenrePopularity(), + genreTTL, + func() ([]domain.GenrePopularity, error) { + return s.repo.GetGenrePopularity(ctx) + }, + ) +} + +func (s *ReportService) GetLowAvailability(ctx context.Context, threshold int) ([]domain.LowAvailabilityBook, error) { + if threshold <= 0 { + threshold = 2 + } + return cache.GetOrSet(ctx, s.cache, + fmt.Sprintf("report:low-availability:%d", threshold), + lowAvailTTL, + func() ([]domain.LowAvailabilityBook, error) { + return s.repo.GetLowAvailability(ctx, threshold) + }, + ) +} + +func (s *ReportService) GetMonthlySummary(ctx context.Context, month string) (*domain.MonthlySummary, error) { + if month == "" { + month = time.Now().Format("2006-01") + } + + if _, err := time.Parse("2006-01", month); err != nil { + return nil, errors.NewValidation(errors.ErrValidationInvalid, + "month must be in YYYY-MM format").WithContext("month", month) + } + + return cache.GetOrSet(ctx, s.cache, + cache.Keys().ReportMonthlySummary(month), + monthlyTTL, + func() (*domain.MonthlySummary, error) { + return s.repo.GetMonthlySummary(ctx, month) + }, + ) +} diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..83fdf2b --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,2 @@ +DROP FUNCTION IF EXISTS trigger_set_updated_at(); +DROP EXTENSION IF EXISTS "pgcrypto"; \ No newline at end of file diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..9e1452b --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE OR REPLACE FUNCTION trigger_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/migrations/000002_create_books.down.sql b/migrations/000002_create_books.down.sql new file mode 100644 index 0000000..eaa9fdc --- /dev/null +++ b/migrations/000002_create_books.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS books; \ No newline at end of file diff --git a/migrations/000002_create_books.up.sql b/migrations/000002_create_books.up.sql new file mode 100644 index 0000000..acad522 --- /dev/null +++ b/migrations/000002_create_books.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE books ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + author VARCHAR(255) NOT NULL, + isbn VARCHAR(20), + genre VARCHAR(100), + publication_year INT, + pages INT, + language VARCHAR(50), + publisher VARCHAR(255), + description TEXT, + total_copies INT NOT NULL DEFAULT 1, + available_copies INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_copies + CHECK (available_copies >= 0 AND available_copies <= total_copies) +); + +CREATE UNIQUE INDEX idx_books_title ON books (title); +CREATE INDEX idx_books_author ON books (author); +CREATE INDEX idx_books_genre ON books (genre); +CREATE UNIQUE INDEX idx_books_isbn ON books (isbn) WHERE isbn IS NOT NULL; + +CREATE TRIGGER set_books_updated_at + BEFORE UPDATE ON books + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); \ No newline at end of file diff --git a/migrations/000003_create_customers.down.sql b/migrations/000003_create_customers.down.sql new file mode 100644 index 0000000..755be08 --- /dev/null +++ b/migrations/000003_create_customers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS customers; \ No newline at end of file diff --git a/migrations/000003_create_customers.up.sql b/migrations/000003_create_customers.up.sql new file mode 100644 index 0000000..f82f93c --- /dev/null +++ b/migrations/000003_create_customers.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255), + email_showcase VARCHAR(255), + national_code VARCHAR(10) UNIQUE, + mobile VARCHAR(11), + password VARCHAR(255) NOT NULL, + birth_date TIMESTAMPTZ, + address TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_customers_email ON customers (email) WHERE email IS NOT NULL; +CREATE UNIQUE INDEX idx_customers_mobile ON customers (mobile) WHERE mobile IS NOT NULL; +CREATE UNIQUE INDEX idx_customers_national_code ON customers (national_code) WHERE national_code IS NOT NULL; + +CREATE TRIGGER set_customers_updated_at + BEFORE UPDATE ON customers + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); \ No newline at end of file diff --git a/migrations/000004_create_employees.down.sql b/migrations/000004_create_employees.down.sql new file mode 100644 index 0000000..abcfcda --- /dev/null +++ b/migrations/000004_create_employees.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS employees; +DROP TYPE IF EXISTS employee_role; \ No newline at end of file diff --git a/migrations/000004_create_employees.up.sql b/migrations/000004_create_employees.up.sql new file mode 100644 index 0000000..9800255 --- /dev/null +++ b/migrations/000004_create_employees.up.sql @@ -0,0 +1,25 @@ +CREATE TYPE employee_role AS ENUM ('librarian', 'manager', 'super_admin'); + +CREATE TABLE employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE, + email_showcase VARCHAR(255) UNIQUE, + mobile VARCHAR(11) UNIQUE, + password VARCHAR(255) NOT NULL, + national_code VARCHAR(10) UNIQUE, + birth_date TIMESTAMPTZ, + role employee_role NOT NULL DEFAULT 'librarian', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_employees_email ON employees (email) WHERE email IS NOT NULL; +CREATE INDEX idx_employees_mobile ON employees (mobile) WHERE mobile IS NOT NULL; +CREATE INDEX idx_employees_national_code ON employees (national_code) WHERE national_code IS NOT NULL; + +CREATE TRIGGER set_employees_updated_at + BEFORE UPDATE ON employees + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); \ No newline at end of file diff --git a/migrations/000005_create_borrows.down.sql b/migrations/000005_create_borrows.down.sql new file mode 100644 index 0000000..5fb8aeb --- /dev/null +++ b/migrations/000005_create_borrows.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS borrows; +DROP TYPE IF EXISTS borrow_status; \ No newline at end of file diff --git a/migrations/000005_create_borrows.up.sql b/migrations/000005_create_borrows.up.sql new file mode 100644 index 0000000..7c3f03e --- /dev/null +++ b/migrations/000005_create_borrows.up.sql @@ -0,0 +1,26 @@ + +CREATE TYPE borrow_status AS ENUM ('active', 'returned', 'overdue'); + +CREATE TABLE borrows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + book_id UUID NOT NULL REFERENCES books(id) ON DELETE RESTRICT, + borrowed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + due_date TIMESTAMPTZ NOT NULL, + returned_at TIMESTAMPTZ, + status borrow_status NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_borrows_active_unique + ON borrows (customer_id, book_id) + WHERE returned_at IS NULL; + +CREATE INDEX idx_borrows_customer_id ON borrows (customer_id); +CREATE INDEX idx_borrows_book_id ON borrows (book_id); +CREATE INDEX idx_borrows_status ON borrows (status); + +CREATE TRIGGER set_borrows_updated_at + BEFORE UPDATE ON borrows + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); \ No newline at end of file diff --git a/migrations/000006_auth_improvements.down.sql b/migrations/000006_auth_improvements.down.sql new file mode 100644 index 0000000..bd936c1 --- /dev/null +++ b/migrations/000006_auth_improvements.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS refresh_tokens; +ALTER TABLE customers + ALTER COLUMN password SET NOT NULL, + DROP COLUMN IF EXISTS google_id, + DROP COLUMN IF EXISTS auth_provider; \ No newline at end of file diff --git a/migrations/000006_auth_improvements.up.sql b/migrations/000006_auth_improvements.up.sql new file mode 100644 index 0000000..48da57b --- /dev/null +++ b/migrations/000006_auth_improvements.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('employee', 'customer')), + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id, user_type); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); + +ALTER TABLE customers + ALTER COLUMN password DROP NOT NULL, + ADD COLUMN google_id VARCHAR(255) UNIQUE, + ADD COLUMN auth_provider VARCHAR(20) NOT NULL DEFAULT 'local' + CHECK (auth_provider IN ('local', 'google')); \ No newline at end of file diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..0bc281f --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,91 @@ +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type Cache struct { + rdb *redis.Client +} + +func New(rdb *redis.Client) *Cache { + return &Cache{rdb: rdb} +} + +func Get[T any](ctx context.Context, c *Cache, key string) (T, bool, error) { + var zero T + data, err := c.rdb.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return zero, false, nil + } + if err != nil { + return zero, false, fmt.Errorf("cache get %q: %w", key, err) + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return zero, false, fmt.Errorf("cache unmarshal %q: %w", key, err) + } + return result, true, nil +} + +func (c *Cache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("cache marshal %q: %w", key, err) + } + return c.rdb.Set(ctx, key, data, ttl).Err() +} + +func (c *Cache) Delete(ctx context.Context, keys ...string) error { + return c.rdb.Del(ctx, keys...).Err() +} + +func GetOrSet[T any](ctx context.Context, c *Cache, key string, ttl time.Duration, fn func() (T, error)) (T, error) { + if result, found, err := Get[T](ctx, c, key); err == nil && found { + return result, nil + } + + result, err := fn() + if err != nil { + return result, err + } + + _ = c.Set(ctx, key, result, ttl) + + return result, nil +} + +func Keys() CacheKeys { return CacheKeys{} } + +type CacheKeys struct{} + +func (CacheKeys) RecommendationItem(bookID string) string { + return fmt.Sprintf("recommendation:item:%s", bookID) +} +func (CacheKeys) RecommendationProfile(customerID string) string { + return fmt.Sprintf("recommendation:profile:%s", customerID) +} +func (CacheKeys) ReportTopBooks(limit int) string { + return fmt.Sprintf("report:top-books:%d", limit) +} +func (CacheKeys) ReportTopCustomers(limit int) string { + return fmt.Sprintf("report:top-customers:%d", limit) +} +func (CacheKeys) ReportGenrePopularity() string { + return "report:genre-popularity" +} +func (CacheKeys) ReportOverdue() string { + return "report:overdue" +} +func (CacheKeys) ReportBorrowTrends(period, from, to string) string { + return fmt.Sprintf("report:borrow-trends:%s:%s:%s", period, from, to) +} +func (CacheKeys) ReportMonthlySummary(month string) string { + return fmt.Sprintf("report:monthly-summary:%s", month) +} diff --git a/pkg/database/pg_mapper.go b/pkg/database/pg_mapper.go new file mode 100644 index 0000000..9794c3c --- /dev/null +++ b/pkg/database/pg_mapper.go @@ -0,0 +1,50 @@ +package database + +import ( + stderrors "errors" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +type ConstraintMap map[string]func() error + +func MapPgError(err error, constraints ConstraintMap) error { + var pgErr *pgconn.PgError + if !stderrors.As(err, &pgErr) { + return nil + } + + switch pgErr.Code { + case "23505": + if fn, ok := constraints[pgErr.ConstraintName]; ok { + return fn() + } + return errors.NewConflict(errors.ErrDBConflict, "duplicate entry"). + WithContext("constraint", pgErr.ConstraintName) + + case "23503": + if fn, ok := constraints[pgErr.ConstraintName]; ok { + return fn() + } + return errors.NewValidation(errors.ErrDBValidation, "referenced record does not exist"). + WithContext("constraint", pgErr.ConstraintName) + + case "23514": + if fn, ok := constraints[pgErr.ConstraintName]; ok { + return fn() + } + return errors.NewValidation(errors.ErrDBValidation, "constraint check failed"). + WithContext("constraint", pgErr.ConstraintName) + + case "23502": + return errors.NewValidation(errors.ErrDBValidation, "required field is null"). + WithContext("column", pgErr.ColumnName) + + case "42601": + return errors.NewValidation(errors.ErrDBValidation, "syntax error"). + WithContext("detail", pgErr.Detail) + } + + return nil +} diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go new file mode 100644 index 0000000..66be2d7 --- /dev/null +++ b/pkg/errors/codes.go @@ -0,0 +1,74 @@ +package errors + +const ( + // Books. + ErrBookNotFound = "BOOK_NOT_FOUND" + ErrBookListFailed = "BOOK_LIST_FAILED" + ErrBookISBNExists = "BOOK_ISBN_EXISTS" + ErrBookAlreadyExists = "BOOK_ALREADY_EXISTS" + ErrBookInvalidCopies = "BOOK_INVALID_COPIES" + ErrBookUpdateFailed = "BOOK_UPDATE_FAILED" + ErrBookDeleteFailed = "BOOK_DELETE_FAILED" + ErrBookInvalidQty = "BOOK_INVALID_QTY" + ErrBookInsufficient = "BOOK_INSUFFICIENT_COPIES" + + // Customers. + ErrCustomerNotFound = "CUSTOMER_NOT_FOUND" + ErrCustomerListFailed = "CUSTOMER_LIST_FAILED" + ErrCustomerEmailExists = "CUSTOMER_EMAIL_EXISTS" + ErrCustomerMobileExists = "CUSTOMER_MOBILE_EXISTS" + ErrCustomerHashFailed = "CUSTOMER_HASH_FAILED" + ErrCustomerUpdateFailed = "CUSTOMER_UPDATE_FAILED" + ErrCustomerDeleteFailed = "CUSTOMER_DELETE_FAILED" + ErrCustomerNationalCodeExists = "CUSTOMER_NATIONAL_CODE_EXISTS" + + // Employees. + ErrEmployeeNotFound = "EMPLOYEE_NOT_FOUND" + ErrEmployeeListFailed = "EMPLOYEE_LIST_FAILED" + ErrEmployeeEmailExists = "EMPLOYEE_EMAIL_EXISTS" + ErrEmployeeMobileExists = "EMPLOYEE_MOBILE_EXISTS" + ErrEmployeeNationalCodeExists = "EMPLOYEE_NATIONAL_EXISTS" + ErrEmployeeInvalidRole = "EMPLOYEE_INVALID_ROLE" + ErrEmployeeHashFailed = "EMPLOYEE_HASH_FAILED" + ErrEmployeeUpdateFailed = "EMPLOYEE_UPDATE_FAILED" + ErrEmployeeEmailConflict = "EMPLOYEE_EMAIL_CONFLICT" + ErrEmployeeDeleteFailed = "EMPLOYEE_DELETE_FAILED" + + // Auth. + ErrAuthInvalidCredentials = "AUTH_INVALID_CREDENTIALS" + ErrAuthEmployeeNotFound = "AUTH_EMPLOYEE_NOT_FOUND" + ErrAuthEmployeeInactive = "AUTH_EMPLOYEE_INACTIVE" + ErrAuthTokenInvalid = "AUTH_TOKEN_INVALID" + ErrAuthTokenExpired = "AUTH_TOKEN_EXPIRED" + ErrAuthTokenFailed = "AUTH_TOKEN_FAILED" + ErrAuthHashFailed = "AUTH_HASH_FAILED" + + // Database. + ErrDBConnectFailed = "DB_CONNECT_FAILED" + ErrDBQueryFailed = "DB_QUERY_FAILED" + ErrDBExecFailed = "DB_EXEC_FAILED" + ErrDBConflict = "DB_CONFLICT" + ErrDBValidation = "DB_VALIDATION" + + // Validation. + ErrValidationRequired = "VALIDATION_REQUIRED" + ErrValidationEmail = "VALIDATION_EMAIL" + ErrValidationInvalid = "VALIDATION_INVALID" + ErrValidationTooShort = "VALIDATION_TOO_SHORT" + ErrValidationTooLong = "VALIDATION_TOO_LONG" + ErrValidationUnknown = "VALIDATION_UNKNOWN" + + // Generic. + ErrInternalError = "INTERNAL_ERROR" + ErrBadRequest = "BAD_REQUEST" + ErrUnauthorized = "UNAUTHORIZED" + ErrForbidden = "FORBIDDEN" + + // Borrow. + ErrBorrowNotFound = "BORROW_NOT_FOUND" + ErrBookUnavailableToBorrow = "BOOK_UNAVAILABLE_TO_BORROW" + ErrBorrowLimitReached = "BORROW_LIMIT_REACHED" + ErrAlreadyBorrowed = "ALREADY_BORROWED" + ErrBorrowNotActive = "BORROW_NOT_ACTIVE" + ErrCustomerInactive = "CUSTOMER_INACTIVE" +) diff --git a/pkg/errors/error.go b/pkg/errors/error.go new file mode 100644 index 0000000..dd59996 --- /dev/null +++ b/pkg/errors/error.go @@ -0,0 +1,166 @@ +package errors + +import ( + "fmt" + "net/http" + "runtime" +) + +type ErrorType string + +const ( + ErrorTypeNotFound ErrorType = "not_found" + ErrorTypeValidation ErrorType = "validation" + ErrorTypeConflict ErrorType = "conflict" + ErrorTypeUnauthorized ErrorType = "unauthorized" + ErrorTypeForbidden ErrorType = "forbidden" + ErrorTypeInternal ErrorType = "internal" +) + +type AppError interface { + error + Code() string + Type() ErrorType + Message() string + Context() map[string]interface{} + InternalContext() map[string]interface{} + WithContext(key string, value interface{}) AppError + WithInternalContext(key string, value interface{}) AppError + HTTPStatus() int +} + +type appError struct { + code string + errorType ErrorType + message string + context map[string]interface{} + internalCtx map[string]interface{} + underlyingError error + stackTrace string +} + +func captureStackTrace() string { + _, file, line, ok := runtime.Caller(2) + if !ok { + return "unknown" + } + return fmt.Sprintf("%s:%d", file, line) +} + +func New(code string, errorType ErrorType, message string) AppError { + return &appError{ + code: code, + errorType: errorType, + message: message, + context: make(map[string]interface{}), + internalCtx: make(map[string]interface{}), + stackTrace: captureStackTrace(), + } +} + +func NewWithUnderlying(code string, errorType ErrorType, message string, underlying error) AppError { + return &appError{ + code: code, + errorType: errorType, + message: message, + context: make(map[string]interface{}), + internalCtx: make(map[string]interface{}), + underlyingError: underlying, + stackTrace: captureStackTrace(), + } +} + +func (e *appError) Error() string { + if e.underlyingError != nil { + return fmt.Sprintf("%s: %s (caused by: %v) at %s", e.code, e.message, e.underlyingError, e.stackTrace) + } + return fmt.Sprintf("%s: %s at %s", e.code, e.message, e.stackTrace) +} + +func (e *appError) Code() string { + return e.code +} + +func (e *appError) Type() ErrorType { + return e.errorType +} + +func (e *appError) Message() string { + return e.message +} + +func (e *appError) Context() map[string]interface{} { + return e.context +} + +func (e *appError) InternalContext() map[string]interface{} { + return e.internalCtx +} + +func (e *appError) WithContext(key string, value interface{}) AppError { + e.context[key] = value + return e +} + +func (e *appError) WithInternalContext(key string, value interface{}) AppError { + e.internalCtx[key] = value + return e +} + +func (e *appError) Unwrap() error { + return e.underlyingError +} + +func (e *appError) HTTPStatus() int { + switch e.errorType { + case ErrorTypeNotFound: + return http.StatusNotFound + case ErrorTypeValidation: + return http.StatusUnprocessableEntity + case ErrorTypeConflict: + return http.StatusConflict + case ErrorTypeUnauthorized: + return http.StatusUnauthorized + case ErrorTypeForbidden: + return http.StatusForbidden + case ErrorTypeInternal: + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + +func NewNotFound(code, message string) AppError { + return New(code, ErrorTypeNotFound, message) +} + +func NewValidation(code, message string) AppError { + return New(code, ErrorTypeValidation, message) +} + +func NewConflict(code, message string) AppError { + return New(code, ErrorTypeConflict, message) +} + +func NewUnauthorized(code, message string) AppError { + return New(code, ErrorTypeUnauthorized, message) +} + +func NewForbidden(code, message string) AppError { + return New(code, ErrorTypeForbidden, message) +} + +func NewInternal(code, message string) AppError { + return New(code, ErrorTypeInternal, message) +} + +func NewInternalWithErr(code, message string, underlying error) AppError { + return NewWithUnderlying(code, ErrorTypeInternal, message, underlying) +} + +func IsNotFound(err error) bool { + if appErr, ok := err.(AppError); ok { + return appErr.Type() == ErrorTypeNotFound + } + return false +} diff --git a/pkg/errors/i18n_mapping.go b/pkg/errors/i18n_mapping.go new file mode 100644 index 0000000..ebce5dd --- /dev/null +++ b/pkg/errors/i18n_mapping.go @@ -0,0 +1,82 @@ +package errors + +import "github.com/mohammad-farrokhnia/library/pkg/i18n" + +func GetMessageCode(errorCode string) i18n.MessageCode { + mapping := map[string]i18n.MessageCode{ + ErrBookNotFound: i18n.MsgBookNotFound, + ErrBookListFailed: i18n.MsgInternalError, + ErrBookISBNExists: i18n.MsgBookISBNExists, + ErrBookInvalidCopies: i18n.MsgValidationFailed, + ErrBookUpdateFailed: i18n.MsgBookNotFound, + ErrBookDeleteFailed: i18n.MsgBookNotFound, + ErrBookInvalidQty: i18n.MsgValidationFailed, + ErrBookInsufficient: i18n.MsgValidationFailed, + ErrBookAlreadyExists: i18n.MsgBookAlreadyExists, + + // Customers. + ErrCustomerNotFound: i18n.MsgCustomerNotFound, + ErrCustomerListFailed: i18n.MsgInternalError, + ErrCustomerEmailExists: i18n.MsgCustomerAlreadyExists, + ErrCustomerMobileExists: i18n.MsgCustomerAlreadyExists, + ErrCustomerHashFailed: i18n.MsgInternalError, + ErrCustomerUpdateFailed: i18n.MsgCustomerNotFound, + ErrCustomerDeleteFailed: i18n.MsgCustomerNotFound, + ErrCustomerNationalCodeExists: i18n.MsgCustomerAlreadyExists, + + // Employees. + ErrEmployeeNotFound: i18n.MsgEmployeeNotFound, + ErrEmployeeListFailed: i18n.MsgInternalError, + ErrEmployeeEmailExists: i18n.MsgValidationFailed, + ErrEmployeeMobileExists: i18n.MsgValidationFailed, + ErrEmployeeNationalCodeExists: i18n.MsgValidationFailed, + ErrEmployeeInvalidRole: i18n.MsgValidationFailed, + ErrEmployeeHashFailed: i18n.MsgInternalError, + ErrEmployeeUpdateFailed: i18n.MsgEmployeeNotFound, + ErrEmployeeEmailConflict: i18n.MsgValidationFailed, + ErrEmployeeDeleteFailed: i18n.MsgEmployeeNotFound, + + // Auth. + ErrAuthInvalidCredentials: i18n.MsgInvalidCredentials, + ErrAuthEmployeeNotFound: i18n.MsgInvalidCredentials, + ErrAuthEmployeeInactive: i18n.MsgUnauthorized, + ErrAuthTokenInvalid: i18n.MsgTokenInvalid, + ErrAuthTokenExpired: i18n.MsgTokenInvalid, + ErrAuthTokenFailed: i18n.MsgInternalError, + ErrAuthHashFailed: i18n.MsgInternalError, + + // Database. + ErrDBConnectFailed: i18n.MsgInternalError, + ErrDBQueryFailed: i18n.MsgInternalError, + ErrDBExecFailed: i18n.MsgInternalError, + ErrDBConflict: i18n.MsgValidationFailed, + ErrDBValidation: i18n.MsgValidationFailed, + + // Validation. + ErrValidationRequired: i18n.MsgValidationFailed, + ErrValidationEmail: i18n.MsgValidationFailed, + ErrValidationInvalid: i18n.MsgValidationFailed, + ErrValidationTooShort: i18n.MsgValidationFailed, + ErrValidationTooLong: i18n.MsgValidationFailed, + ErrValidationUnknown: i18n.MsgBadRequest, + + // Generic. + ErrInternalError: i18n.MsgInternalError, + ErrBadRequest: i18n.MsgBadRequest, + ErrUnauthorized: i18n.MsgUnauthorized, + ErrForbidden: i18n.MsgForbidden, + + // Borrow. + ErrBorrowNotFound: i18n.MsgBorrowNotFound, + ErrBookUnavailableToBorrow: i18n.MsgBookUnavailableToBorrow, + ErrBorrowLimitReached: i18n.MsgBorrowLimitReached, + ErrAlreadyBorrowed: i18n.MsgAlreadyBorrowed, + ErrBorrowNotActive: i18n.MsgBorrowNotActive, + ErrCustomerInactive: i18n.MsgCustomerInactive, + } + + if code, ok := mapping[errorCode]; ok { + return code + } + return i18n.MsgInternalError +} diff --git a/pkg/i18n/codes.go b/pkg/i18n/codes.go new file mode 100644 index 0000000..612b6b8 --- /dev/null +++ b/pkg/i18n/codes.go @@ -0,0 +1,71 @@ +package i18n + +type MessageCode string + +const ( + //System. + MsgHealthOK MessageCode = "HEALTH_OK" + MsgTooManyRequests MessageCode = "TOO_MANY_REQUESTS" + + // Generic success. + MsgFetched MessageCode = "FETCHED" + MsgCreated MessageCode = "CREATED" + MsgUpdated MessageCode = "UPDATED" + MsgDeleted MessageCode = "DELETED" + + // Generic errors. + MsgBadRequest MessageCode = "BAD_REQUEST" + MsgUnauthorized MessageCode = "UNAUTHORIZED" + MsgForbidden MessageCode = "FORBIDDEN" + MsgNotFound MessageCode = "NOT_FOUND" + MsgValidationFailed MessageCode = "VALIDATION_FAILED" + MsgInternalError MessageCode = "INTERNAL_ERROR" + + //Books. + BookRecordNotFound MessageCode = "BOOK_RECORD_NOT_FOUND" + MsgBookNotFound MessageCode = "BOOK_NOT_FOUND" + MsgBookUpdated MessageCode = "BOOK_UPDATED" + MsgBookCreated MessageCode = "BOOK_CREATED" + MsgBookFound MessageCode = "BOOK_FOUND" + MsgBooksRetrieved MessageCode = "BOOKS_RETRIEVED" + MsgBookISBNExists MessageCode = "BOOK_ISBN_EXISTS" + MsgBookAlreadyExists MessageCode = "BOOK_ALREADY_EXISTS" + + //Customers. + MsgCustomerNotFound MessageCode = "CUSTOMER_NOT_FOUND" + MsgCustomerUpdated MessageCode = "CUSTOMER_UPDATED" + MsgCustomerCreated MessageCode = "CUSTOMER_CREATED" + MsgCustomerFound MessageCode = "CUSTOMER_FOUND" + MsgCustomerAlreadyExists MessageCode = "CUSTOMER_ALREADY_EXISTS" + MsgCustomerInactive MessageCode = "CUSTOMER_INACTIVE" + + //Auth. + MsgInvalidCredentials MessageCode = "INVALID_CREDENTIALS" + MsgLoginSuccess MessageCode = "LOGIN_SUCCESS" + MsgTokenInvalid MessageCode = "TOKEN_INVALID" + + //Employees. + MsgEmployeeNotFound MessageCode = "EMPLOYEE_NOT_FOUND" + MsgEmployeeUpdated MessageCode = "EMPLOYEE_UPDATED" + MsgEmployeeCreated MessageCode = "EMPLOYEE_CREATED" + MsgEmployeeFound MessageCode = "EMPLOYEE_FOUND" + MsgEmployeeAlreadyExists MessageCode = "EMPLOYEE_ALREADY_EXISTS" + + // Borrow. + MsgBorrowNotFound MessageCode = "BORROW_NOT_FOUND" + MsgBookUnavailableToBorrow MessageCode = "BOOK_UNAVAILABLE_TO_BORROW" + MsgBorrowLimitReached MessageCode = "BORROW_LIMIT_REACHED" + MsgAlreadyBorrowed MessageCode = "ALREADY_BORROWED" + MsgBorrowNotActive MessageCode = "BORROW_NOT_ACTIVE" + MsgBorrowSuccess MessageCode = "BORROW_SUCCESS" + MsgReturnSuccess MessageCode = "RETURN_SUCCESS" + + MsgRecommendationsFound MessageCode = "RECOMMENDATIONS_FOUND" + MsgNoRecommendations MessageCode = "NO_RECOMMENDATIONS" + + // Recommendation reasons. + MsgReasonItemBased MessageCode = "RECOMMEND_REASON_ITEM_BASED" + MsgReasonSameGenre MessageCode = "RECOMMEND_REASON_SAME_GENRE" + MsgReasonProfileBased MessageCode = "RECOMMEND_REASON_PROFILE_BASED" + MsgReasonPopular MessageCode = "RECOMMEND_REASON_POPULAR" +) diff --git a/pkg/i18n/en.go b/pkg/i18n/en.go new file mode 100644 index 0000000..ec38503 --- /dev/null +++ b/pkg/i18n/en.go @@ -0,0 +1,51 @@ +package i18n + +var enMessages = map[MessageCode]string{ + MsgHealthOK: "Service is up and running.", + MsgFetched: "Fetched successfully.", + MsgCreated: "Created successfully.", + MsgUpdated: "Updated successfully.", + MsgDeleted: "Deleted successfully.", + MsgBadRequest: "Bad request.", + MsgUnauthorized: "Unauthorized.", + MsgForbidden: "Forbidden.", + MsgNotFound: "Resource not found.", + MsgValidationFailed: "Validation failed.", + MsgInternalError: "Internal server error.", + BookRecordNotFound: "Book record not found.", + MsgBookNotFound: "Book not found.", + MsgBookUpdated: "Book updated successfully.", + MsgBookCreated: "Book created successfully.", + MsgBookFound: "Book found.", + MsgBooksRetrieved: "Books retrieved successfully.", + MsgBookAlreadyExists: "A book with this ISBN already exists.", + MsgBookISBNExists: "A book with this ISBN already exists.", + MsgCustomerNotFound: "Customer not found.", + MsgCustomerUpdated: "Customer updated successfully.", + MsgCustomerCreated: "Customer created successfully.", + MsgCustomerFound: "Customer found.", + MsgInvalidCredentials: "Invalid credentials.", + MsgLoginSuccess: "Login successful.", + MsgTokenInvalid: "Invalid or expired token.", + MsgEmployeeNotFound: "Employee not found.", + MsgEmployeeUpdated: "Employee updated successfully.", + MsgEmployeeCreated: "Employee created successfully.", + MsgEmployeeFound: "Employee found.", + MsgBorrowNotFound: "Borrow not found.", + MsgBookUnavailableToBorrow: "Book is not available for borrowing.", + MsgBorrowLimitReached: "You have reached the maximum number of active borrows.", + MsgAlreadyBorrowed: "You have already borrowed this book.", + MsgBorrowNotActive: "This borrow has already been returned.", + MsgBorrowSuccess: "Book borrowed successfully.", + MsgReturnSuccess: "Book returned successfully.", + MsgRecommendationsFound: "Recommendations retrieved successfully.", + MsgNoRecommendations: "No recommendations available yet.", + MsgReasonItemBased: "Customers who borrowed this book also borrowed", + MsgReasonSameGenre: "More books in the same genre", + MsgReasonProfileBased: "Based on your borrowing history", + MsgReasonPopular: "Most popular in the library", + MsgTooManyRequests: "Too many requests. Please slow down and try again shortly.", + MsgEmployeeAlreadyExists: "Employee with this email already exists.", + MsgCustomerAlreadyExists: "Customer with this email already exists.", + MsgCustomerInactive: "Customer account is inactive.", +} diff --git a/pkg/i18n/fa.go b/pkg/i18n/fa.go new file mode 100644 index 0000000..078f06b --- /dev/null +++ b/pkg/i18n/fa.go @@ -0,0 +1,47 @@ +//nolint:staticcheck // U+200C (ZWNJ) is intentional Persian typography +package i18n + +var faMessages = map[MessageCode]string{ + MsgHealthOK: "سرویس در حال اجرا است.", + MsgFetched: "با موفقیت دریافت شد.", + MsgCreated: "با موفقیت ایجاد شد.", + MsgUpdated: "با موفقیت به‌روزرسانی شد.", + MsgDeleted: "با موفقیت حذف شد.", + MsgBadRequest: "درخواست نامعتبر.", + MsgUnauthorized: "غیرمجاز.", + MsgForbidden: "ممنوع.", + MsgNotFound: "منبع یافت نشد.", + MsgValidationFailed: "اعتبارسنجی ناموفق.", + MsgInternalError: "خطای داخلی سرور.", + BookRecordNotFound: "رکورد کتاب یافت نشد.", + MsgBookNotFound: "کتاب یافت نشد.", + MsgBookUpdated: "کتاب با موفقیت به‌روزرسانی شد.", + MsgBookCreated: "کتاب با موفقیت ایجاد شد.", + MsgBookFound: "کتاب یافت شد.", + MsgBooksRetrieved: "کتاب‌ها با موفقیت دریافت شدند.", + MsgBookAlreadyExists: "کتابی با این شناسه وجود دارد.", + MsgCustomerNotFound: "مشتری یافت نشد.", + MsgCustomerUpdated: "مشتری با موفقیت به‌روزرسانی شد.", + MsgCustomerCreated: "مشتری با موفقیت ایجاد شد.", + MsgCustomerFound: "مشتری یافت شد.", + MsgInvalidCredentials: "اعتبارنامه نامعتبر.", + MsgLoginSuccess: "ورود موفق.", + MsgTokenInvalid: "توکن نامعتبر یا منقضی شده.", + MsgEmployeeNotFound: "کارمند یافت نشد.", + MsgEmployeeUpdated: "کارمند با موفقیت به‌روزرسانی شد.", + MsgEmployeeCreated: "کارمند با موفقیت ایجاد شد.", + MsgEmployeeFound: "کارمند یافت شد.", + MsgBorrowNotFound: "امانت یافت نشد.", + MsgBookUnavailableToBorrow: "کتاب برای امانت در دسترس نیست.", + MsgBorrowLimitReached: "شما به حداکثر تعداد امانت‌های فعال رسیده‌اید.", + MsgAlreadyBorrowed: "شما قبلاً این کتاب را امانت گرفته‌اید.", + MsgBorrowNotActive: "این امانت قبلاً بازگردانده شده است.", + MsgBorrowSuccess: "کتاب با موفقیت امانت گرفته شد.", + MsgReturnSuccess: "کتاب با موفقیت بازگردانده شد.", + MsgRecommendationsFound: "پیشنهادات با موفقیت دریافت شدند.", + MsgNoRecommendations: "در حال حاضر پیشنهادی موجود نیست.", + MsgTooManyRequests: "درخواست‌های بیش از حد. لطفاً کمی صبر کرده و دوباره تلاش کنید.", + MsgEmployeeAlreadyExists: "کارمند با این ایمیل قبلاً وجود دارد.", + MsgCustomerAlreadyExists: "مشتری با این ایمیل قبلاً وجود دارد.", + MsgCustomerInactive: "حساب کاربری مشتری غیرفعال است.", +} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go new file mode 100644 index 0000000..acd7132 --- /dev/null +++ b/pkg/i18n/i18n.go @@ -0,0 +1,54 @@ +package i18n + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +type Lang string + +const ( + LangEN Lang = "en" + LangFA Lang = "fa" +) + +var translations = map[Lang]map[MessageCode]string{ + LangEN: enMessages, + LangFA: faMessages, +} + +func Translate(c *gin.Context, code MessageCode) string { + lang := detectLang(c) + + if msgs, ok := translations[lang]; ok { + if msg, ok := msgs[code]; ok { + return msg + } + } + + if msg, ok := translations[LangEN][code]; ok { + return msg + } + + return string(code) +} + +func detectLang(c *gin.Context) Lang { + header := c.GetHeader("Accept-Language") + if header == "" { + return LangEN + } + + parts := strings.Split(header, ",") + for _, part := range parts { + tag := strings.Split(strings.TrimSpace(part), ";")[0] + primary := strings.ToLower(strings.Split(tag, "-")[0]) + + if _, supported := translations[Lang(primary)]; supported { + return Lang(primary) + } + } + + return LangEN +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..da049c1 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,292 @@ +package logger + +import ( + "log" + "runtime/debug" + + "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +var ( + appName string +) + +func Init(name string) { + appName = name +} + +type Level string + +const ( + LevelDebug Level = "debug" + LevelInfo Level = "info" + LevelWarn Level = "warn" + LevelError Level = "error" +) + +type LogEntry struct { + Level Level + AppName string + RequestID string + ErrorCode string + ErrorType string + UserID string + Operation string + Message string + Context map[string]interface{} + InternalCtx map[string]interface{} + Stack string +} + +func logEntry(entry LogEntry) { + var prefix string + switch entry.Level { + case LevelDebug: + prefix = "[DEBUG]" + case LevelInfo: + prefix = "[INFO]" + case LevelWarn: + prefix = "[WARN]" + case LevelError: + prefix = "[ERROR]" + } + + logMsg := prefix + if entry.AppName != "" { + logMsg += " [" + entry.AppName + "]" + } + if entry.RequestID != "" { + logMsg += " reqId=" + entry.RequestID + } + if entry.ErrorCode != "" { + logMsg += " errCode=" + entry.ErrorCode + } + if entry.ErrorType != "" { + logMsg += " errType=" + entry.ErrorType + } + if entry.UserID != "" { + logMsg += " userId=" + entry.UserID + } + if entry.Operation != "" { + logMsg += " op=" + entry.Operation + } + if entry.Message != "" { + logMsg += " " + entry.Message + } + + log.Println(logMsg) + + if entry.Level == LevelError && len(entry.InternalCtx) > 0 { + for k, v := range entry.InternalCtx { + log.Printf(" [INTERNAL] %s=%v", k, v) + } + } + + if entry.Level == LevelError && entry.Stack != "" { + log.Printf(" [STACK] %s", entry.Stack) + } +} + +func Debug(message string) { + logEntry(LogEntry{ + Level: LevelDebug, + AppName: appName, + Message: message, + }) +} + +func DebugWithFields(message string, fields map[string]interface{}) { + logEntry(LogEntry{ + Level: LevelDebug, + AppName: appName, + Message: message, + Context: fields, + }) +} + +func Info(message string) { + logEntry(LogEntry{ + Level: LevelInfo, + AppName: appName, + Message: message, + }) +} + +func InfoWithFields(message string, fields map[string]interface{}) { + logEntry(LogEntry{ + Level: LevelInfo, + AppName: appName, + Message: message, + Context: fields, + }) +} + +func Warn(message string) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + Message: message, + }) +} + +func WarnWithFields(message string, fields map[string]interface{}) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + Message: message, + Context: fields, + }) +} + +func Error(message string) { + logEntry(LogEntry{ + Level: LevelError, + AppName: appName, + Message: message, + }) +} + +func ErrorWithFields(message string, fields map[string]interface{}) { + logEntry(LogEntry{ + Level: LevelError, + AppName: appName, + Message: message, + Context: fields, + }) +} + +func LogAppError(appErr errors.AppError, requestID string) { + entry := LogEntry{ + Level: LevelError, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + Context: appErr.Context(), + InternalCtx: appErr.InternalContext(), + } + + if appErr.Type() == errors.ErrorTypeInternal { + entry.Stack = string(debug.Stack()) + } + + logEntry(entry) +} + +func LogAppErrorWithUser(appErr errors.AppError, requestID, userID string) { + entry := LogEntry{ + Level: LevelError, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + UserID: userID, + Message: appErr.Message(), + Context: appErr.Context(), + InternalCtx: appErr.InternalContext(), + } + + if appErr.Type() == errors.ErrorTypeInternal { + entry.Stack = string(debug.Stack()) + } + + logEntry(entry) +} + +func LogAppErrorWithOperation(appErr errors.AppError, requestID, operation string) { + entry := LogEntry{ + Level: LevelError, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Operation: operation, + Message: appErr.Message(), + Context: appErr.Context(), + InternalCtx: appErr.InternalContext(), + } + + if appErr.Type() == errors.ErrorTypeInternal { + entry.Stack = string(debug.Stack()) + } + + logEntry(entry) +} + +func LogValidation(appErr errors.AppError, requestID string) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + Context: appErr.Context(), + }) +} + +func LogNotFound(appErr errors.AppError, requestID string) { + logEntry(LogEntry{ + Level: LevelInfo, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + }) +} + +func LogConflict(appErr errors.AppError, requestID string) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + Context: appErr.Context(), + }) +} + +func LogUnauthorized(appErr errors.AppError, requestID string) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + }) +} + +func LogForbidden(appErr errors.AppError, requestID string) { + logEntry(LogEntry{ + Level: LevelWarn, + AppName: appName, + RequestID: requestID, + ErrorCode: appErr.Code(), + ErrorType: string(appErr.Type()), + Message: appErr.Message(), + }) +} + +func LogGeneric(err error, requestID string) { + logEntry(LogEntry{ + Level: LevelError, + AppName: appName, + RequestID: requestID, + Message: err.Error(), + Stack: string(debug.Stack()), + }) +} + +func GetRequestID(requestID interface{}) string { + if requestID == nil { + return "" + } + if str, ok := requestID.(string); ok { + return str + } + return "" +} diff --git a/pkg/mailer/mailer.go b/pkg/mailer/mailer.go new file mode 100644 index 0000000..13142b2 --- /dev/null +++ b/pkg/mailer/mailer.go @@ -0,0 +1,31 @@ +package mailer + +import "log" + +// TODO: PRODUCTION WARNING - MockMailer logs OTPs instead of sending emails. +// Before deploying to production, implement a real Mailer using: +// - SendGrid (github.com/sendgrid/sendgrid-go) +// - AWS SES (github.com/aws/aws-sdk-go-v2/service/ses) +// - Mailgun, Postmark, etc. +// Without this, password reset and email verification will NOT work. + +type Mailer interface { + SendOTP(email, otp string) error + SendWelcome(email, firstName string) error +} + +type MockMailer struct{} + +func NewMock() Mailer { + return &MockMailer{} +} + +func (m *MockMailer) SendOTP(email, otp string) error { + log.Printf("[mailer:mock] OTP for %s: %s", email, otp) + return nil +} + +func (m *MockMailer) SendWelcome(email, firstName string) error { + log.Printf("[mailer:mock] Welcome email for %s <%s>", firstName, email) + return nil +} diff --git a/pkg/otp/otp.go b/pkg/otp/otp.go new file mode 100644 index 0000000..b1d1911 --- /dev/null +++ b/pkg/otp/otp.go @@ -0,0 +1,44 @@ +package otp + +import ( + "context" + "crypto/subtle" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const mockOTP = "123456" + +type Store struct { + rdb *redis.Client + ttl time.Duration +} + +func NewStore(rdb *redis.Client, ttl time.Duration) *Store { + return &Store{rdb: rdb, ttl: ttl} +} + +func otpKey(userType, email string) string { + return fmt.Sprintf("otp:%s:%s", userType, email) +} + +func (s *Store) Generate(ctx context.Context, email, userType string) (string, error) { + // TODO: replace with a real random 6-digit code when email provider is added + code := mockOTP + err := s.rdb.Set(ctx, otpKey(userType, email), code, s.ttl).Err() + return code, err +} + +func (s *Store) Verify(ctx context.Context, email, userType, code string) (bool, error) { + stored, err := s.rdb.Get(ctx, otpKey(userType, email)).Result() + if err != nil { + return false, nil + } + return subtle.ConstantTimeCompare([]byte(stored), []byte(code)) == 1, nil +} + +func (s *Store) Delete(ctx context.Context, email, userType string) error { + return s.rdb.Del(ctx, otpKey(userType, email)).Err() +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..a87b3e0 --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,232 @@ +package response + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/mohammad-farrokhnia/library/pkg/errors" + "github.com/mohammad-farrokhnia/library/pkg/i18n" + "github.com/mohammad-farrokhnia/library/pkg/logger" +) + +var ( + appName string + version string +) + +func Init(name, ver string) { + appName = name + version = ver +} + +type Meta struct { + AppName string `json:"appName" example:"library" description:"Application name"` + Version string `json:"version" example:"1.0" description:"API version"` + RequestID string `json:"requestId" example:"550e8400-e29b-41d4-a716-446655440000" description:"Unique request identifier"` + Timestamp string `json:"timestamp" example:"2024-01-01T00:00:00Z" description:"Response timestamp in RFC3339 format"` + MessageCode string `json:"messageCode" example:"HEALTH_OK" description:"Internal message code for i18n"` + Message string `json:"message" example:"Service is up and running." description:"Human-readable message"` +} + +func newMeta(c *gin.Context, code i18n.MessageCode) Meta { + reqID, _ := c.Get("X-Request-Id") + if reqID == nil { + reqID = "—" + } + return Meta{ + AppName: appName, + Version: version, + RequestID: reqID.(string), + Timestamp: time.Now().UTC().Format(time.RFC3339), + MessageCode: string(code), + Message: i18n.Translate(c, code), + } +} + +type FieldError struct { + Field string `json:"field" example:"email" description:"Field name that failed validation"` + Message string `json:"message" example:"Invalid email format" description:"Error message describing the validation failure"` +} + +type ErrorBody struct { + Fields []FieldError `json:"fields,omitempty" description:"List of field-specific validation errors"` +} + +type Response struct { + Data any `json:"data" description:"Response payload"` + Meta Meta `json:"meta" description:"Response metadata"` +} + +type ErrorResponse struct { + Error ErrorBody `json:"error" description:"Error details"` + Meta Meta `json:"meta" description:"Response metadata"` +} + +func OK(c *gin.Context, data any, code i18n.MessageCode) { + c.JSON(http.StatusOK, Response{ + Data: data, + Meta: newMeta(c, code), + }) +} + +func OKWithPagination(c *gin.Context, data any, code i18n.MessageCode, total int64, page, pageSize int) { + if pageSize < 1 { + pageSize = 10 + } + if page < 1 { + page = 1 + } + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + pagination := Pagination{ + Page: page, + PerPage: pageSize, + Total: int(total), + TotalPages: totalPages, + } + c.JSON(http.StatusOK, gin.H{ + "data": data, + "meta": newMeta(c, code), + "pagination": pagination, + }) +} + +func Created(c *gin.Context, data any, code i18n.MessageCode) { + c.JSON(http.StatusCreated, gin.H{ + "data": data, + "meta": newMeta(c, code), + }) +} + +func NoContent(c *gin.Context) { + c.Status(http.StatusNoContent) +} + +func List(c *gin.Context, data any, code i18n.MessageCode, p *Pagination) { + c.JSON(http.StatusOK, gin.H{ + "data": data, + "meta": newMeta(c, code), + "pagination": p, + }) +} + +type Pagination struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + Total int `json:"total"` + TotalPages int `json:"totalPages"` +} + +func Error(c *gin.Context, status int, code i18n.MessageCode) { + c.JSON(status, gin.H{ + "error": ErrorBody{}, + "meta": newMeta(c, code), + }) + c.Abort() +} + +func ValidationError(c *gin.Context, fields map[string]string) { + errs := make([]FieldError, 0, len(fields)) + for f, msg := range fields { + errs = append(errs, FieldError{Field: f, Message: msg}) + } + code := i18n.MsgValidationFailed + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": ErrorBody{ + Fields: errs, + }, + "meta": newMeta(c, code), + }) + c.Abort() +} + +func BadRequest(c *gin.Context, code i18n.MessageCode) { + Error(c, http.StatusBadRequest, code) +} + +func Unauthorized(c *gin.Context) { + Error(c, http.StatusUnauthorized, i18n.MsgUnauthorized) +} + +func Forbidden(c *gin.Context) { + Error(c, http.StatusForbidden, i18n.MsgForbidden) +} + +func NotFound(c *gin.Context, code i18n.MessageCode) { + Error(c, http.StatusNotFound, code) +} + +func InternalError(c *gin.Context) { + Error(c, http.StatusInternalServerError, i18n.MsgInternalError) +} + +func HandleAppError(c *gin.Context, err error) { + if appErr, ok := err.(errors.AppError); ok { + reqID, _ := c.Get("X-Request-Id") + requestID := "" + if reqID != nil { + requestID = reqID.(string) + } + + switch appErr.Type() { + case errors.ErrorTypeValidation: + logger.LogValidation(appErr, requestID) + case errors.ErrorTypeNotFound: + logger.LogNotFound(appErr, requestID) + case errors.ErrorTypeConflict: + logger.LogConflict(appErr, requestID) + case errors.ErrorTypeUnauthorized: + logger.LogUnauthorized(appErr, requestID) + case errors.ErrorTypeForbidden: + logger.LogForbidden(appErr, requestID) + case errors.ErrorTypeInternal: + logger.LogAppError(appErr, requestID) + } + + msgCode := errors.GetMessageCode(appErr.Code()) + errBody := ErrorBody{} + if len(appErr.Context()) > 0 { + fields := make([]FieldError, 0) + for k, v := range appErr.Context() { + fields = append(fields, FieldError{ + Field: k, + Message: fmt.Sprintf("%v", v), + }) + } + errBody.Fields = fields + } + + c.JSON(appErr.HTTPStatus(), gin.H{ + "error": errBody, + "meta": Meta{ + AppName: appName, + Version: version, + RequestID: requestID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + MessageCode: string(msgCode), + Message: i18n.Translate(c, msgCode), + }, + }) + c.Abort() + return + } + + InternalError(c) +} + +// ParseUUIDParam extracts and validates a UUID path parameter. +// It writes a 400 Bad Request and returns ("", false) if the value is not a valid UUID. +func ParseUUIDParam(c *gin.Context, param string) (string, bool) { + raw := c.Param(param) + if _, err := uuid.Parse(raw); err != nil { + BadRequest(c, i18n.MsgBadRequest) + return "", false + } + return raw, true +} diff --git a/pkg/session/session.go b/pkg/session/session.go new file mode 100644 index 0000000..c5094eb --- /dev/null +++ b/pkg/session/session.go @@ -0,0 +1,71 @@ +package session + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type Store struct { + rdb *redis.Client +} + +func NewStore(rdb *redis.Client) *Store { + return &Store{rdb: rdb} +} + +func sessionKey(jti string) string { + return fmt.Sprintf("session:%s", jti) +} + +func userSessionsKey(userType, userID string) string { + return fmt.Sprintf("user_sessions:%s:%s", userType, userID) +} + +func (s *Store) Create(ctx context.Context, jti, userID, userType string, ttl time.Duration) error { + pipe := s.rdb.Pipeline() + + pipe.Set(ctx, sessionKey(jti), userID, ttl) + + pipe.SAdd(ctx, userSessionsKey(userType, userID), jti) + pipe.Expire(ctx, userSessionsKey(userType, userID), ttl) + + _, err := pipe.Exec(ctx) + return err +} + +func (s *Store) Exists(ctx context.Context, jti string) (bool, error) { + result, err := s.rdb.Exists(ctx, sessionKey(jti)).Result() + return result > 0, err +} + +func (s *Store) Delete(ctx context.Context, jti, userID, userType string) error { + pipe := s.rdb.Pipeline() + pipe.Del(ctx, sessionKey(jti)) + pipe.SRem(ctx, userSessionsKey(userType, userID), jti) + _, err := pipe.Exec(ctx) + return err +} + +func (s *Store) DeleteAllForUser(ctx context.Context, userID, userType string) error { + key := userSessionsKey(userType, userID) + + jtis, err := s.rdb.SMembers(ctx, key).Result() + if err != nil { + return err + } + + if len(jtis) == 0 { + return nil + } + + pipe := s.rdb.Pipeline() + for _, jti := range jtis { + pipe.Del(ctx, sessionKey(jti)) + } + pipe.Del(ctx, key) + _, err = pipe.Exec(ctx) + return err +} diff --git a/pkg/util/email.go b/pkg/util/email.go new file mode 100644 index 0000000..bd995cf --- /dev/null +++ b/pkg/util/email.go @@ -0,0 +1,19 @@ +package util + +import "strings" + +func StripEmailSymbols(email string) string { + email = strings.ReplaceAll(email, ".", "") + email = strings.ReplaceAll(email, "+", "") + return email +} + +func StripEmailLocalPartSymbols(email string) string { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return email + } + localPart := strings.ReplaceAll(parts[0], ".", "") + localPart = strings.ReplaceAll(localPart, "+", "") + return localPart + "@" + parts[1] +} diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go new file mode 100644 index 0000000..c3fd7cd --- /dev/null +++ b/pkg/validator/validator.go @@ -0,0 +1,60 @@ +package validator + +import ( + "fmt" + "regexp" + "strings" +) + +var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + +type Validator struct { + errors map[string]string +} + +func New() *Validator { + return &Validator{errors: make(map[string]string)} +} + +func (v *Validator) Required(field string, value string) *Validator { + if strings.TrimSpace(value) == "" { + v.errors[field] = "required" + } + return v +} + +func (v *Validator) Email(field, value string) *Validator { + if value != "" && !emailRegex.MatchString(value) { + v.errors[field] = "must be a valid email address" + } + return v +} + +func (v *Validator) Min(field string, value, minimum int) *Validator { + if value < minimum { + v.errors[field] = fmt.Sprintf("must be at least %d", minimum) + } + return v +} + +func (v *Validator) MaxLen(field, value string, maximum int) *Validator { + if len(value) > maximum { + v.errors[field] = fmt.Sprintf("must not exceed %d characters", maximum) + } + return v +} + +func (v *Validator) Custom(field string, failed bool, message string) *Validator { + if failed { + v.errors[field] = message + } + return v +} + +func (v *Validator) HasErrors() bool { + return len(v.errors) > 0 +} + +func (v *Validator) Errors() map[string]string { + return v.errors +} diff --git a/tests/helpers/cache.go b/tests/helpers/cache.go new file mode 100644 index 0000000..237514a --- /dev/null +++ b/tests/helpers/cache.go @@ -0,0 +1,21 @@ +package helpers + +import ( + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/pkg/cache" +) + +func NewTestCache(t *testing.T) *cache.Cache { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + return cache.New(rdb) +} diff --git a/tests/helpers/gin.go b/tests/helpers/gin.go new file mode 100644 index 0000000..409a27c --- /dev/null +++ b/tests/helpers/gin.go @@ -0,0 +1,55 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func NewRouter() *gin.Engine { + return gin.New() +} + +func NewRequest(t *testing.T, method, path string, body any) *http.Request { + t.Helper() + var buf bytes.Buffer + if body != nil { + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + } + req, err := http.NewRequestWithContext(context.Background(), method, path, &buf) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + return req +} + +func Do(router *gin.Engine, req *http.Request) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func DecodeResponse(t *testing.T, w *httptest.ResponseRecorder, dest any) { + t.Helper() + require.NoError(t, json.NewDecoder(w.Body).Decode(dest)) +} + +func SetCustomerClaims(router *gin.Engine, customerID string, handler gin.HandlerFunc) { + router.GET("/test", func(c *gin.Context) { + c.Set("claims", &domain.Claims{ + UserID: customerID, + SubjectType: domain.SubjectTypeCustomer, + }) + handler(c) + }) +} diff --git a/tests/integration/borrow_test.go b/tests/integration/borrow_test.go new file mode 100644 index 0000000..00b282b --- /dev/null +++ b/tests/integration/borrow_test.go @@ -0,0 +1,135 @@ +package integration_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/mohammad-farrokhnia/library/internal/repository" + "github.com/mohammad-farrokhnia/library/internal/service" + pkgerrors "github.com/mohammad-farrokhnia/library/pkg/errors" +) + +func TestBorrowFlow_FullCycle(t *testing.T) { + cleanupTables(t) + ctx := context.Background() + + bookRepo := repository.NewBookRepository(env.DB) + customerRepo := repository.NewCustomerRepository(env.DB) + borrowRepo := repository.NewBorrowRepository(env.DB) + borrowSvc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + bookSvc := service.NewBookService(bookRepo, nil) + customerSvc := service.NewCustomerService(customerRepo) + + book, err := bookSvc.Create(ctx, domain.CreateBookInput{ + Title: "1984", + Author: "George Orwell", + Language: "English", + Publisher: "Secker", + Pages: 328, + TotalCopies: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, book.AvailableCopies) + + nationalCode := "1234567890" + email := "john@test.com" + mobile := "09123456789" + customer, err := customerSvc.Create(ctx, domain.CreateCustomerInput{ + FirstName: "John", + LastName: "Doe", + Email: &email, + NationalCode: &nationalCode, + Mobile: &mobile, + }) + require.NoError(t, err) + + borrow, err := borrowSvc.Borrow(ctx, domain.CreateBorrowInput{ + CustomerID: customer.ID, + BookID: book.ID, + }) + require.NoError(t, err) + assert.Equal(t, domain.BorrowStatusActive, borrow.Status) + + updated, err := bookRepo.GetByID(ctx, book.ID) + require.NoError(t, err) + assert.Equal(t, 1, updated.AvailableCopies) + _, err = borrowSvc.Borrow(ctx, domain.CreateBorrowInput{ + CustomerID: customer.ID, + BookID: book.ID, + }) + require.Error(t, err) + appErr, ok := err.(pkgerrors.AppError) + require.True(t, ok) + assert.Equal(t, pkgerrors.ErrAlreadyBorrowed, appErr.Code()) + + returned, err := borrowSvc.Return(ctx, borrow.ID) + require.NoError(t, err) + assert.Equal(t, domain.BorrowStatusReturned, returned.Status) + + afterReturn, err := bookRepo.GetByID(ctx, book.ID) + require.NoError(t, err) + assert.Equal(t, 2, afterReturn.AvailableCopies) + + _, err = borrowSvc.Return(ctx, borrow.ID) + require.Error(t, err) + appErr, ok = err.(pkgerrors.AppError) + require.True(t, ok) + assert.Equal(t, pkgerrors.ErrBorrowNotActive, appErr.Code()) +} + +func TestBorrowFlow_LimitEnforced(t *testing.T) { + cleanupTables(t) + ctx := context.Background() + + bookRepo := repository.NewBookRepository(env.DB) + customerRepo := repository.NewCustomerRepository(env.DB) + borrowRepo := repository.NewBorrowRepository(env.DB) + borrowSvc := service.NewBorrowService(borrowRepo, bookRepo, customerRepo) + bookSvc := service.NewBookService(bookRepo, nil) + customerSvc := service.NewCustomerService(customerRepo) + nationalCode := "0987654321" + email := "jane@test.com" + mobile := "09120000001" + customer, _ := customerSvc.Create(ctx, domain.CreateCustomerInput{ + FirstName: "Jane", LastName: "Smith", + Email: &email, NationalCode: &nationalCode, Mobile: &mobile, + }) + + books := make([]*domain.Book, 4) + for i := range books { + isbn := fmt.Sprintf("97800000000%d", i) + b, err := bookSvc.Create(ctx, domain.CreateBookInput{ + Title: fmt.Sprintf("Book %d", i), + Author: "Author", + Language: "English", + Publisher: "Pub", + Pages: 100, + TotalCopies: 5, + ISBN: &isbn, + }) + require.NoError(t, err) + books[i] = b + } + + for i := 0; i < 3; i++ { + _, err := borrowSvc.Borrow(ctx, domain.CreateBorrowInput{ + CustomerID: customer.ID, + BookID: books[i].ID, + }) + require.NoError(t, err, "borrow %d should succeed", i+1) + } + + _, err := borrowSvc.Borrow(ctx, domain.CreateBorrowInput{ + CustomerID: customer.ID, + BookID: books[3].ID, + }) + require.Error(t, err) + appErr, ok := err.(pkgerrors.AppError) + require.True(t, ok) + assert.Equal(t, pkgerrors.ErrBorrowLimitReached, appErr.Code()) +} diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go new file mode 100644 index 0000000..45d4392 --- /dev/null +++ b/tests/integration/setup_test.go @@ -0,0 +1,132 @@ +package integration_test + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "testing" + "time" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" +) + +type testEnv struct { + DB *sqlx.DB + RDB *redis.Client +} + +var env *testEnv + +func TestMain(m *testing.M) { + ctx := context.Background() + + pgContainer, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("testdb"), + tcpostgres.WithUsername("test"), + tcpostgres.WithPassword("test"), + ) + if err != nil { + fmt.Printf("postgres container: %v\n", err) + os.Exit(1) + } + + connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable") + + db, err := sqlx.Open("pgx", connStr) + if err != nil { + fmt.Printf("db open: %v\n", err) + os.Exit(1) + } + + var pingErr error + for i := 0; i < 10; i++ { + pingErr = db.Ping() + if pingErr == nil { + break + } + time.Sleep(500 * time.Millisecond) + } + if pingErr != nil { + fmt.Printf("db ping: %v\n", pingErr) + os.Exit(1) + } + if err := runMigrations(connStr); err != nil { + fmt.Printf("migrations: %v\n", err) + os.Exit(1) + } + + redisContainer, err := tcredis.Run(ctx, "redis:7-alpine") + if err != nil { + fmt.Printf("redis container: %v\n", err) + os.Exit(1) + } + + redisEndpoint, _ := redisContainer.Endpoint(ctx, "") + rdb := redis.NewClient(&redis.Options{Addr: redisEndpoint}) + + env = &testEnv{DB: db, RDB: rdb} + + code := m.Run() + + closeDb(db) + closeRdb(rdb) + _ = pgContainer.Terminate(ctx) + _ = redisContainer.Terminate(ctx) + + os.Exit(code) +} + +func runMigrations(dbURL string) error { + m, err := migrate.New( + "file://../../migrations", + strings.Replace(dbURL, "postgres://", "pgx5://", 1), + ) + if err != nil { + return err + } + defer closeMigrate(m) + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return err + } + return nil +} + +func cleanupTables(t *testing.T) { + t.Helper() + _, err := env.DB.Exec(` + TRUNCATE TABLE borrows, books, customers, employees, + refresh_tokens + RESTART IDENTITY CASCADE`) + if err != nil { + t.Fatalf("cleanup: %v", err) + } +} + +func closeDb(db *sqlx.DB) { + if err := db.Close(); err != nil { + log.Printf("db close: %v", err) + } +} + +func closeRdb(rdb *redis.Client) { + if err := rdb.Close(); err != nil { + log.Printf("redis close: %v", err) + } +} + +func closeMigrate(mig *migrate.Migrate) { + + if err, db := mig.Close(); err != nil { + log.Printf("migration close: %v db:%v", err, db) + } +} diff --git a/tests/mocks/book_repo_mock.go b/tests/mocks/book_repo_mock.go new file mode 100644 index 0000000..291ba3a --- /dev/null +++ b/tests/mocks/book_repo_mock.go @@ -0,0 +1,62 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type MockBookRepository struct { + mock.Mock +} + +func (m *MockBookRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Book, int64, error) { + args := m.Called(ctx, params) + return args.Get(0).([]domain.Book), args.Get(1).(int64), args.Error(2) +} + +func (m *MockBookRepository) GetByID(ctx context.Context, id string) (*domain.Book, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Book), args.Error(1) +} + +func (m *MockBookRepository) Create(ctx context.Context, input domain.CreateBookInput) (*domain.Book, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Book), args.Error(1) +} + +func (m *MockBookRepository) Update(ctx context.Context, id string, input domain.UpdateBookInput) (*domain.Book, error) { + args := m.Called(ctx, id, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Book), args.Error(1) +} + +func (m *MockBookRepository) Delete(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + +func (m *MockBookRepository) AddCopies(ctx context.Context, id string, qty int) (*domain.Book, error) { + args := m.Called(ctx, id, qty) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Book), args.Error(1) +} + +func (m *MockBookRepository) RemoveCopies(ctx context.Context, id string, qty int) (*domain.Book, error) { + args := m.Called(ctx, id, qty) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Book), args.Error(1) +} diff --git a/tests/mocks/borrow_repo_mock.go b/tests/mocks/borrow_repo_mock.go new file mode 100644 index 0000000..c16efbf --- /dev/null +++ b/tests/mocks/borrow_repo_mock.go @@ -0,0 +1,57 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type MockBorrowRepository struct { + mock.Mock +} + +func (m *MockBorrowRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + args := m.Called(ctx, params) + return args.Get(0).([]domain.BorrowDetail), args.Get(1).(int64), args.Error(2) +} + +func (m *MockBorrowRepository) GetByID(ctx context.Context, id string) (*domain.BorrowDetail, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.BorrowDetail), args.Error(1) +} + +func (m *MockBorrowRepository) GetActiveByCustomer(ctx context.Context, customerID string) ([]domain.BorrowDetail, error) { + args := m.Called(ctx, customerID) + return args.Get(0).([]domain.BorrowDetail), args.Error(1) +} + +func (m *MockBorrowRepository) GetAllByCustomer(ctx context.Context, customerID, status string, params domain.QueryParams) ([]domain.BorrowDetail, int64, error) { + args := m.Called(ctx, customerID, status, params) + return args.Get(0).([]domain.BorrowDetail), args.Get(1).(int64), args.Error(2) +} + +func (m *MockBorrowRepository) CountActiveByCustomer(ctx context.Context, customerID string) (int, error) { + args := m.Called(ctx, customerID) + return args.Int(0), args.Error(1) +} + +func (m *MockBorrowRepository) Create(ctx context.Context, input domain.CreateBorrowInput, dueDays int) (*domain.Borrow, error) { + args := m.Called(ctx, input, dueDays) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Borrow), args.Error(1) +} + +func (m *MockBorrowRepository) Return(ctx context.Context, borrowID string) (*domain.Borrow, error) { + args := m.Called(ctx, borrowID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Borrow), args.Error(1) +} diff --git a/tests/mocks/customer_repo_mock.go b/tests/mocks/customer_repo_mock.go new file mode 100644 index 0000000..3b7fc10 --- /dev/null +++ b/tests/mocks/customer_repo_mock.go @@ -0,0 +1,80 @@ +package mocks + +import ( + "context" + "github.com/mohammad-farrokhnia/library/internal/domain" + "github.com/stretchr/testify/mock" +) + +type MockCustomerRepository struct { + mock.Mock +} + +func (m *MockCustomerRepository) UpdateProfile(ctx context.Context, id string, input domain.UpdateCustomerProfileInput) error { + return m.Called(ctx, id, input).Error(0) +} + +func (m *MockCustomerRepository) GetByID(ctx context.Context, id string) (*domain.Customer, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) GetByEmail(ctx context.Context, email string) (*domain.Customer, error) { + args := m.Called(ctx, email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) GetByMobile(ctx context.Context, mobile string) (*domain.Customer, error) { + args := m.Called(ctx, mobile) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) GetAll(ctx context.Context, params domain.QueryParams) ([]domain.Customer, int64, error) { + args := m.Called(ctx, params) + return args.Get(0).([]domain.Customer), args.Get(1).(int64), args.Error(2) +} + +func (m *MockCustomerRepository) Create(ctx context.Context, input domain.CreateCustomerInput) (*domain.Customer, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) Update(ctx context.Context, id string, input domain.UpdateCustomerInput) (*domain.Customer, error) { + args := m.Called(ctx, id, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) Delete(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + +func (m *MockCustomerRepository) UpdatePassword(ctx context.Context, id, hashed string) error { + return m.Called(ctx, id, hashed).Error(0) +} + +func (m *MockCustomerRepository) GetByGoogleID(ctx context.Context, googleID string) (*domain.Customer, error) { + args := m.Called(ctx, googleID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Customer), args.Error(1) +} + +func (m *MockCustomerRepository) LinkGoogleID(ctx context.Context, customerID, googleID string) error { + return m.Called(ctx, customerID, googleID).Error(0) +} diff --git a/tests/mocks/employee_repo_mock.go b/tests/mocks/employee_repo_mock.go new file mode 100644 index 0000000..7ca6c84 --- /dev/null +++ b/tests/mocks/employee_repo_mock.go @@ -0,0 +1,74 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type MockEmployeeRepository struct { + mock.Mock +} + +func (m *MockEmployeeRepository) Create(ctx context.Context, input domain.CreateEmployeeInput, hashedPassword string) (*domain.Employee, error) { + args := m.Called(ctx, input, hashedPassword) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) GetByID(ctx context.Context, id string) (*domain.Employee, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) GetByEmail(ctx context.Context, email string) (*domain.Employee, error) { + args := m.Called(ctx, email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) GetByMobile(ctx context.Context, mobile string) (*domain.Employee, error) { + args := m.Called(ctx, mobile) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) GetByNationalCode(ctx context.Context, nationalCode string) (*domain.Employee, error) { + args := m.Called(ctx, nationalCode) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) Update(ctx context.Context, id string, input domain.UpdateEmployeeInput) (*domain.Employee, error) { + args := m.Called(ctx, id, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Employee), args.Error(1) +} + +func (m *MockEmployeeRepository) Delete(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + +func (m *MockEmployeeRepository) List(ctx context.Context, params domain.QueryParams) ([]domain.Employee, int64, error) { + args := m.Called(ctx, params) + return args.Get(0).([]domain.Employee), args.Get(1).(int64), args.Error(2) +} + +func (m *MockEmployeeRepository) UpdatePassword(ctx context.Context, id string, password string) error { + return m.Called(ctx, id, password).Error(0) +} diff --git a/tests/mocks/recommendation_repo_mock.go b/tests/mocks/recommendation_repo_mock.go new file mode 100644 index 0000000..c60d28a --- /dev/null +++ b/tests/mocks/recommendation_repo_mock.go @@ -0,0 +1,33 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type MockRecommendationRepository struct { + mock.Mock +} + +func (m *MockRecommendationRepository) GetItemBased(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) { + args := m.Called(ctx, bookID, limit) + return args.Get(0).([]domain.BookWithScore), args.Error(1) +} + +func (m *MockRecommendationRepository) GetProfileBased(ctx context.Context, customerID string, limit int) ([]domain.BookWithScore, error) { + args := m.Called(ctx, customerID, limit) + return args.Get(0).([]domain.BookWithScore), args.Error(1) +} + +func (m *MockRecommendationRepository) GetPopular(ctx context.Context, limit int) ([]domain.BookWithScore, error) { + args := m.Called(ctx, limit) + return args.Get(0).([]domain.BookWithScore), args.Error(1) +} + +func (m *MockRecommendationRepository) GetSameGenre(ctx context.Context, bookID string, limit int) ([]domain.BookWithScore, error) { + args := m.Called(ctx, bookID, limit) + return args.Get(0).([]domain.BookWithScore), args.Error(1) +} diff --git a/tests/mocks/report_repo_mock.go b/tests/mocks/report_repo_mock.go new file mode 100644 index 0000000..14fc1bc --- /dev/null +++ b/tests/mocks/report_repo_mock.go @@ -0,0 +1,56 @@ +package mocks + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/mohammad-farrokhnia/library/internal/domain" +) + +type MockReportRepository struct { + mock.Mock +} + +func (m *MockReportRepository) GetBorrowTrends(ctx context.Context, period, from, to string) ([]domain.BorrowTrend, error) { + args := m.Called(ctx, period, from, to) + return args.Get(0).([]domain.BorrowTrend), args.Error(1) +} + +func (m *MockReportRepository) GetTopBooks(ctx context.Context, limit int) ([]domain.TopBook, error) { + args := m.Called(ctx, limit) + return args.Get(0).([]domain.TopBook), args.Error(1) +} + +func (m *MockReportRepository) GetOverdue(ctx context.Context) ([]domain.OverdueBorrow, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.OverdueBorrow), args.Error(1) +} + +func (m *MockReportRepository) GetTopCustomers(ctx context.Context, limit int) ([]domain.TopCustomer, error) { + args := m.Called(ctx, limit) + return args.Get(0).([]domain.TopCustomer), args.Error(1) +} + +func (m *MockReportRepository) GetGenrePopularity(ctx context.Context) ([]domain.GenrePopularity, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.GenrePopularity), args.Error(1) +} + +func (m *MockReportRepository) GetLowAvailability(ctx context.Context, threshold int) ([]domain.LowAvailabilityBook, error) { + args := m.Called(ctx, threshold) + return args.Get(0).([]domain.LowAvailabilityBook), args.Error(1) +} + +func (m *MockReportRepository) GetMonthlySummary(ctx context.Context, month string) (*domain.MonthlySummary, error) { + args := m.Called(ctx, month) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.MonthlySummary), args.Error(1) +} + +func (m *MockReportRepository) MarkOverdueBorrows(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +}