From b055b8ba0cd12b2e94bedaf9f844035a43ebf90d Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 9 May 2026 23:19:19 +0530 Subject: [PATCH] feat: add books APIs --- .env.example | 38 ++ .gitignore | 33 ++ AGENTS.md | 0 CLAUDE.md | 1 + Makefile | 35 ++ PROJECT_CONTEXT.md | 417 ++++++++++++++ cmd/api/main.go | 73 +++ go.mod | 24 + go.sum | 66 +++ internal/config/config.go | 112 ++++ internal/handler/book.go | 633 +++++++++++++++++++++ internal/handler/disciple.go | 93 +++ internal/handler/essay.go | 40 ++ internal/handler/glossary.go | 97 ++++ internal/handler/health.go | 22 + internal/handler/journal.go | 65 +++ internal/handler/letter.go | 103 ++++ internal/handler/magazine.go | 65 +++ internal/handler/other_document.go | 65 +++ internal/handler/transcription.go | 129 +++++ internal/middleware/auth.go | 57 ++ internal/middleware/logger.go | 32 ++ internal/middleware/ratelimit.go | 81 +++ internal/model/content.go | 116 ++++ internal/model/user.go | 60 ++ internal/repository/.gitkeep | 0 internal/repository/book_repo.go | 211 +++++++ internal/repository/disciple_repo.go | 160 ++++++ internal/repository/essay_repo.go | 64 +++ internal/repository/glossary_repo.go | 220 +++++++ internal/repository/journal_repo.go | 89 +++ internal/repository/letter_repo.go | 166 ++++++ internal/repository/magazine_repo.go | 89 +++ internal/repository/mongo.go | 54 ++ internal/repository/other_document_repo.go | 89 +++ internal/repository/transcription_repo.go | 233 ++++++++ internal/server/server.go | 331 +++++++++++ internal/service/.gitkeep | 0 migrations/.gitkeep | 0 pkg/push/.gitkeep | 0 pkg/response/response.go | 67 +++ pkg/shorturl/.gitkeep | 0 pkg/validator/validator.go | 77 +++ 43 files changed, 4307 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 PROJECT_CONTEXT.md create mode 100644 cmd/api/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/book.go create mode 100644 internal/handler/disciple.go create mode 100644 internal/handler/essay.go create mode 100644 internal/handler/glossary.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/journal.go create mode 100644 internal/handler/letter.go create mode 100644 internal/handler/magazine.go create mode 100644 internal/handler/other_document.go create mode 100644 internal/handler/transcription.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/model/content.go create mode 100644 internal/model/user.go create mode 100644 internal/repository/.gitkeep create mode 100644 internal/repository/book_repo.go create mode 100644 internal/repository/disciple_repo.go create mode 100644 internal/repository/essay_repo.go create mode 100644 internal/repository/glossary_repo.go create mode 100644 internal/repository/journal_repo.go create mode 100644 internal/repository/letter_repo.go create mode 100644 internal/repository/magazine_repo.go create mode 100644 internal/repository/mongo.go create mode 100644 internal/repository/other_document_repo.go create mode 100644 internal/repository/transcription_repo.go create mode 100644 internal/server/server.go create mode 100644 internal/service/.gitkeep create mode 100644 migrations/.gitkeep create mode 100644 pkg/push/.gitkeep create mode 100644 pkg/response/response.go create mode 100644 pkg/shorturl/.gitkeep create mode 100644 pkg/validator/validator.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4573d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Server +APP_ENV=development +PORT=8080 + +# MongoDB (content) +MONGO_URI=mongodb://user:pass@localhost:27017/spsays?authSource=admin +MONGO_DB=spsays + +# PostgreSQL (users, relations) +POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable + +# Meilisearch +MEILISEARCH_HOST=http://localhost:7700 +MEILISEARCH_API_KEY= + +# DragonflyDB / Redis +REDIS_URI=redis://localhost:6379 + +# S3 +S3_REGION= +S3_BUCKET= +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_ENDPOINT= + +# Push Notifications +FCM_CREDENTIALS_FILE= +APNS_KEY_FILE= +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= + +# JWT +JWT_SECRET=change-me-in-production +JWT_EXPIRATION=24h + +# Frontend +FRONTEND_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..361e287 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Build +/tmp/ +/dist/ + +# Environment +.env +.env.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +*.test +coverage.out +coverage.html + +# Vendor (if not committed) +# /vendor/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e69de29 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd9cd30 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: build run dev test clean lint + +# Binary name +BINARY=spsays-bff +BUILD_DIR=./bin + +# Build +build: + @echo "Building..." + @go build -o $(BUILD_DIR)/$(BINARY) ./cmd/api + +# Run (build + execute) +run: build + @$(BUILD_DIR)/$(BINARY) + +# Dev (with live reload — requires air: go install github.com/air-verse/air@latest) +dev: + @air -c .air.toml || go run ./cmd/api + +# Test +test: + @go test -v -race -cover ./... + +# Clean +clean: + @rm -rf $(BUILD_DIR) + @go clean + +# Lint (requires golangci-lint) +lint: + @golangci-lint run ./... + +# Tidy dependencies +tidy: + @go mod tidy diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..59901c1 --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,417 @@ +# Prabhupāda Says — BFF (Backend for Frontend) + +## What this project is + +A Go-based Backend for Frontend (BFF) API server for [prabhupadasays.org](https://prabhupadasays.org). It sits between the Next.js frontend (and future mobile apps) and the data layer — aggregating content from MongoDB, user data from PostgreSQL, search from Meilisearch, caching from DragonflyDB, and media from S3 into a unified API surface. + +--- + +## Tech Stack + +- **Go 1.23** — API server +- **Chi v5** — lightweight HTTP router with middleware support +- **Zap** — structured logging (uber-go/zap) +- **MongoDB** — content database (books, verses, letters, essays, glossary, disciples, transcriptions) +- **PostgreSQL** — relational data (users, bookmarks, collections, mentions, URL shortener) +- **Meilisearch** — typo-tolerant full-text search across all content +- **DragonflyDB** — Redis-compatible cache (API responses, rate limiting, sessions) +- **AWS S3** — media storage (avatars, images, audio files) +- **FCM + APNs** — push notifications for Android and iOS +- **JWT** — stateless authentication + +--- + +## Architecture + +``` +┌──────────────┐ ┌─────────────────────────────────────────┐ +│ Next.js │────▶│ Go BFF API (:8080) │ +│ (frontend) │ │ │ +├──────────────┤ │ cmd/api/main.go → server.go → handlers │ +│ Mobile Apps │────▶│ │ +│ (future) │ │ ┌──────────┐ ┌───────────────┐ │ +└──────────────┘ │ │ Handlers │──│ Services │ │ + │ └──────────┘ └───────┬───────┘ │ + │ │ │ + │ ┌─────────────────────┼──────────┐ │ + │ ▼ ▼ ▼ ▼ ▼ │ + │ Mongo Postgres Meili S3 Dragonfly│ + │ (content)(users, (search)(media)(cache)│ + │ mentions, │ + │ URL short, │ + │ relations) │ + └─────────────────────────────────────────┘ +``` + +--- + +## Project Structure + +``` +spsays-bff/ +├── cmd/ +│ └── api/ +│ └── main.go → Entry point (config, logger, server, graceful shutdown) +├── internal/ +│ ├── config/ +│ │ └── config.go → Environment config (all services: Mongo, Postgres, Meili, S3, etc.) +│ ├── server/ +│ │ └── server.go → Chi router setup, global middleware, route registration +│ ├── handler/ → HTTP handlers (one file per domain) +│ │ ├── health.go → Health check endpoint +│ │ ├── book.go → Book routes (SB, CC, KB, BG, generic verse & prose) +│ │ ├── letter.go → Letter routes (by year, recipient, location) +│ │ ├── essay.go → Essay routes +│ │ ├── glossary.go → Glossary routes (letters, terms) +│ │ ├── disciple.go → Disciple routes (filter, search, aggregations) +│ │ ├── journal.go → Journal routes +│ │ ├── magazine.go → Magazine routes +│ │ ├── other_document.go → Other-document routes +│ │ └── transcription.go → Transcription routes (by year, place, type, slug) +│ ├── service/ → Business logic layer (orchestrates repositories) +│ ├── repository/ → Data access layer (Mongo, Postgres, Meili, S3, Dragonfly) +│ │ ├── mongo.go → MongoDB connection + database handle +│ │ ├── book_repo.go → Book / verse queries +│ │ ├── letter_repo.go → Letter queries +│ │ ├── essay_repo.go → Essay queries +│ │ ├── glossary_repo.go → Glossary queries +│ │ ├── disciple_repo.go → Disciple queries +│ │ ├── journal_repo.go → Journal queries +│ │ ├── magazine_repo.go → Magazine queries +│ │ ├── other_document_repo.go → Other-document queries +│ │ └── transcription_repo.go → Transcription queries (with contentJson fallback) +│ ├── middleware/ +│ │ ├── logger.go → Zap-based request logging (method, path, status, duration) +│ │ ├── auth.go → JWT Bearer token authentication + context user extraction +│ │ └── ratelimit.go → In-memory token bucket rate limiter (→ DragonflyDB in production) +│ └── model/ +│ ├── content.go → Content models (Book, Verse, Letter, Essay, GlossaryEntry, +│ │ Disciple, Transcription, ContentBlock, Synonym) +│ └── user.go → User models (User, Bookmark, Collection, Mention, ShortURL) +├── pkg/ → Public packages (importable by other projects) +│ ├── response/ +│ │ └── response.go → Standardized API response helpers (JSON, Error, Envelope, Meta) +│ ├── validator/ +│ │ └── validator.go → Field validation (Required, MaxLength, Email, In, etc.) +│ ├── push/ → Push notification sender (FCM + APNs) — placeholder +│ └── shorturl/ → URL shortener logic — placeholder +├── migrations/ → PostgreSQL migrations (goose or golang-migrate) +├── go.mod → Module: github.com/prabhupadasays/spsays-bff +├── go.sum +├── Makefile → build, run, dev, test, clean, lint, tidy +├── .env.example → Template for all environment variables +└── .gitignore +``` + +--- + +## API Routes + +All routes are prefixed with `/api/v1`. + +### Health + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/health` | Server health check | + +### Books + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/books` | List all books (lightweight summaries) | +| `GET` | `/api/v1/books/sb` | SB book metadata | +| `GET` | `/api/v1/books/sb/{canto}` | SB canto chapter listing | +| `GET` | `/api/v1/books/sb/{canto}/{chapter}` | SB chapter verse index | +| `GET` | `/api/v1/books/sb/{canto}/{chapter}/verses` | SB paginated verses | +| `GET` | `/api/v1/books/sb/{canto}/{chapter}/{verse}` | SB single verse | +| `GET` | `/api/v1/books/cc` | CC book metadata | +| `GET` | `/api/v1/books/cc/{lila}` | CC līlā chapter listing | +| `GET` | `/api/v1/books/cc/{lila}/{chapter}` | CC chapter verse index | +| `GET` | `/api/v1/books/cc/{lila}/{chapter}/verses` | CC paginated verses | +| `GET` | `/api/v1/books/cc/{lila}/{chapter}/{verse}` | CC single verse | +| `GET` | `/api/v1/books/kb` | KB book metadata | +| `GET` | `/api/v1/books/kb/{volume}` | KB volume chapter listing | +| `GET` | `/api/v1/books/kb/{volume}/{slug}` | KB single chapter | +| `GET` | `/api/v1/books/bg` | BG book metadata | +| `GET` | `/api/v1/books/bg/{chapter}` | BG chapter verse index | +| `GET` | `/api/v1/books/bg/{chapter}/verses` | BG paginated verses | +| `GET` | `/api/v1/books/bg/{chapter}/{verse}` | BG single verse | +| `GET` | `/api/v1/books/{code}` | Any other book metadata | +| `GET` | `/api/v1/books/{code}/chapters` | Prose book chapter listing (no paragraphs) | +| `GET` | `/api/v1/books/{code}/chapters/{slug}` | Prose book single chapter | +| `GET` | `/api/v1/books/{code}/{chapter}` | Verse book chapter index | +| `GET` | `/api/v1/books/{code}/{chapter}/verses` | Verse book paginated verses | +| `GET` | `/api/v1/books/{code}/{chapter}/{verse}` | Verse book single verse | + +### Letters + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/letters` | Total letter count | +| `GET` | `/api/v1/letters/years` | Year → count aggregation | +| `GET` | `/api/v1/letters/recipients` | Recipient → count aggregation | +| `GET` | `/api/v1/letters/locations` | Location → count aggregation | +| `GET` | `/api/v1/letters/year/{year}` | Letters for a year | +| `GET` | `/api/v1/letters/recipient/{name}` | Letters for a recipient | +| `GET` | `/api/v1/letters/location/{name}` | Letters from a location | +| `GET` | `/api/v1/letters/{slug}` | Single letter | + +### Essays + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/essays` | All essay summaries | +| `GET` | `/api/v1/essays/{slug}` | Single essay | + +### Glossary + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/glossary` | All glossary entries | +| `GET` | `/api/v1/glossary/letters` | Letter → count mapping | +| `GET` | `/api/v1/glossary/letter/{letter}` | Entries for a letter (with diacritical grouping) | +| `GET` | `/api/v1/glossary/letter/{letter}/terms` | Term names only for sidebar | +| `GET` | `/api/v1/glossary/term/{term}` | Single entry by term | + +### Disciples + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/disciples` | Filtered list (?letter=&suffix=) | +| `GET` | `/api/v1/disciples/count` | Total + filtered counts | +| `GET` | `/api/v1/disciples/letters` | Letter → count aggregation | +| `GET` | `/api/v1/disciples/suffixes` | Suffix → count aggregation | +| `GET` | `/api/v1/disciples/search` | Search by name (?q=) | + +### Journals + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/journals` | All journal metadata | +| `GET` | `/api/v1/journals/{book}` | Single journal meta | +| `GET` | `/api/v1/journals/{book}/chapters` | Journal chapters | +| `GET` | `/api/v1/journals/{book}/chapters/{slug}` | Single chapter | + +### Magazines + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/magazines` | All magazine metadata | +| `GET` | `/api/v1/magazines/{book}` | Single magazine meta | +| `GET` | `/api/v1/magazines/{book}/issues` | Magazine issues | +| `GET` | `/api/v1/magazines/{book}/issues/{slug}` | Single issue | + +### Other Documents + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/other-documents` | All document metadata | +| `GET` | `/api/v1/other-documents/{book}` | Single document meta | +| `GET` | `/api/v1/other-documents/{book}/chapters` | Document chapters | +| `GET` | `/api/v1/other-documents/{book}/chapters/{slug}` | Single chapter | + +### Transcriptions + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/transcriptions` | Total transcription count | +| `GET` | `/api/v1/transcriptions/years` | Year → count aggregation | +| `GET` | `/api/v1/transcriptions/places` | Place → count aggregation | +| `GET` | `/api/v1/transcriptions/types` | Type distribution (`{lectures, conversations}`) | +| `GET` | `/api/v1/transcriptions/year/{year}` | Transcriptions for a year (summaries) | +| `GET` | `/api/v1/transcriptions/place/{place}` | Transcriptions for a place (summaries) | +| `GET` | `/api/v1/transcriptions/type/{type}` | Transcriptions by type (`lecture` or `conversation`) | +| `GET` | `/api/v1/transcriptions/adjacent/{slug}` | Prev/next within same year (`?year=N`) | +| `GET` | `/api/v1/transcriptions/{slug}` | Single transcription (full content + audio URL) | + +### Authenticated (JWT required) — planned + +| Method | Route | Description | +|--------|-------|-------------| +| `POST` | `/api/v1/auth/register` | User registration | +| `POST` | `/api/v1/auth/login` | User login (returns JWT) | +| `POST` | `/api/v1/auth/refresh` | Refresh JWT token | +| `GET` | `/api/v1/users/me` | Get current user profile | +| `PUT` | `/api/v1/users/me` | Update current user profile | +| `GET` | `/api/v1/collections` | List user's collections | +| `POST` | `/api/v1/collections` | Create a collection | +| `POST` | `/api/v1/collections/:id/items` | Add item to collection | +| `DELETE` | `/api/v1/collections/:id/items/:itemId` | Remove item from collection | +| `GET` | `/api/v1/mentions` | List user's mentions | +| `POST` | `/api/v1/mentions` | Create a mention | +| `POST` | `/api/v1/s` | Create a short URL | +| `POST` | `/api/v1/notifications/register` | Register device for push notifications | + +--- + +## Data Models + +### Content Models (MongoDB) + +| Model | Collection | Description | +|-------|------------|-------------| +| `Book` | `book_meta` | Book metadata (title, author, chapters, etc.) | +| `Verse` | `verses` | Individual verses (BG, SB, CC, ISO, NOI, BS, MM, NBS) | +| `Letter` | `letters` | 6,800+ letters (1947–1977) | +| `Essay` | `essays` | 23 standalone essays | +| `GlossaryEntry` | `glossary` | 2,749 Sanskrit/Vedic terms | +| `Disciple` | `disciples` | 4,747 initiated disciples | +| `Transcription` | `transcriptions` | 3,787 lectures, conversations, philosophy discussions, SB dictations | + +### User Models (PostgreSQL) + +| Model | Table | Description | +|-------|-------|-------------| +| `User` | `users` | User accounts (email, name, avatar, role) | +| `Bookmark` | `bookmarks` | Saved content references with optional notes | +| `Collection` | `collections` | User-created collections of bookmarks | +| `Mention` | `mentions` | Content shared/tagged by users | +| `ShortURL` | `short_urls` | Shortened URLs with click tracking | + +--- + +## API Response Format + +All responses follow a consistent envelope: + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "page": 1, + "limit": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +Error responses: + +```json +{ + "success": false, + "error": { + "code": 400, + "message": "validation failed" + } +} +``` + +--- + +## Middleware Stack + +| Middleware | Description | +|-----------|-------------| +| `RequestID` | Generates unique request ID (chi built-in) | +| `RealIP` | Extracts real client IP from proxy headers | +| `Logger` | Zap-based structured request logging | +| `Recoverer` | Panic recovery (chi built-in) | +| `Compress` | Gzip compression (level 5) | +| `CORS` | Cross-origin resource sharing (frontend URL whitelisted) | +| `Auth` | JWT Bearer token validation (applied to protected routes) | +| `RateLimit` | Token bucket rate limiter (in-memory → DragonflyDB in production) | + +--- + +## Environment Variables + +``` +# Server +APP_ENV=development # "development" | "production" +PORT=8080 + +# MongoDB (content) +MONGO_URI=mongodb://... # required +MONGO_DB=spsays + +# PostgreSQL (users, relations) +POSTGRES_URI=postgres://... + +# Meilisearch +MEILISEARCH_HOST=http://localhost:7700 +MEILISEARCH_API_KEY= + +# DragonflyDB / Redis +REDIS_URI=redis://localhost:6379 + +# S3 +S3_REGION= +S3_BUCKET= +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_ENDPOINT= # optional — for MinIO or S3-compatible services + +# Push Notifications +FCM_CREDENTIALS_FILE= # path to Firebase service account JSON +APNS_KEY_FILE= # path to APNs auth key (.p8) +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= + +# JWT +JWT_SECRET= # required in production +JWT_EXPIRATION=24h + +# Frontend (CORS) +FRONTEND_URL=http://localhost:3000 +``` + +--- + +## How to Run + +```bash +# Setup +cp .env.example .env # then fill in values + +# Development (with live reload — requires air) +make dev + +# Build + run +make run + +# Build only +make build + +# Run tests +make test + +# Lint (requires golangci-lint) +make lint + +# Tidy deps +make tidy +``` + +--- + +## Relationship to Other Projects + +| Project | Path | Description | +|---------|------|-------------| +| **knowledgebase** | `../knowledgebase/` | Next.js frontend — consumes this BFF's API | +| **extractor** | `../extractor/` | Python/TS extraction scripts + raw data + seed scripts | +| **spsays-bff** | `./` (this project) | Go BFF API server | + +The frontend currently fetches directly from MongoDB via Prisma. The migration plan: +1. ✅ Done — BFF API routes matching current Prisma queries +2. Add Meilisearch indexing + search endpoints +3. Add PostgreSQL for user features (auth, bookmarks, collections, mentions) +4. Switch frontend from Prisma to BFF API calls +5. Add mobile app support (same API, push notifications) + +--- + +## Design Principles + +- **`internal/`** — private to this module (handlers, services, repositories, middleware, models, config) +- **`pkg/`** — public packages that could be imported by other projects (response helpers, validators, push sender) +- **Repository pattern** — data access isolated behind interfaces (easy to swap Mongo ↔ Postgres, mock for tests) +- **Service layer** — business logic between handlers and repositories (orchestration, validation, caching) +- **Handler layer** — thin HTTP handlers that parse requests, call services, return responses +- **No ORM** — use native drivers (`mongo-go-driver`, `pgx`) for full control +- **Explicit book routing** — SB, CC, and KB have dedicated route groups to avoid URL ambiguity with generic `{code}/{chapter}/{verse}` patterns \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..e5241e8 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/config" + "github.com/prabhupadasays/spsays-bff/internal/server" + "go.uber.org/zap" +) + +func main() { + // Load config + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) + } + + // Initialize logger + var logger *zap.Logger + if cfg.Env == "production" { + logger, _ = zap.NewProduction() + } else { + logger, _ = zap.NewDevelopment() + } + defer logger.Sync() + + sugar := logger.Sugar() + + // Create server + srv := server.New(cfg, logger) + + // Start HTTP server + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: srv.Router(), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Graceful shutdown + go func() { + sugar.Infof("🚀 Server starting on port %d (%s)", cfg.Port, cfg.Env) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + sugar.Fatalf("server error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + sugar.Info("🛑 Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + sugar.Fatalf("forced shutdown: %v", err) + } + + // Close connections + srv.Close() + + sugar.Info("✅ Server stopped gracefully") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb7038c --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/prabhupadasays/spsays-bff + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/cors v1.2.1 + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver/v2 v2.2.1 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/golang/snappy v1.0.0 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..97d16e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.2.1 h1:w5xra3yyu/sGrziMzK1D0cRRaH/b7lWCSsoN6+WV6AM= +go.mongodb.org/mongo-driver/v2 v2.2.1/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..adac4a4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +// Config holds all configuration for the application. +type Config struct { + // Server + Env string // "development" | "production" + Port int + + // MongoDB (content database) + MongoURI string + MongoDB string + + // PostgreSQL (users, relations, URL shortener) + PostgresURI string + + // Meilisearch + MeilisearchHost string + MeilisearchAPIKey string + + // DragonflyDB / Redis + RedisURI string + + // AWS S3 + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3Endpoint string // optional — for S3-compatible services (MinIO, etc.) + + // Push Notifications + FCMCredentialsFile string // path to Firebase service account JSON + APNsKeyFile string // path to APNs auth key + APNsKeyID string + APNsTeamID string + APNsBundleID string + + // JWT Auth + JWTSecret string + JWTExpiration string // e.g., "24h" + + // Frontend + FrontendURL string // for CORS +} + +// Load reads configuration from environment variables (with .env fallback). +func Load() (*Config, error) { + // Load .env file if present (ignored in production) + _ = godotenv.Load() + + cfg := &Config{ + Env: getEnv("APP_ENV", "development"), + Port: getEnvInt("PORT", 8080), + + MongoURI: getEnv("MONGO_URI", ""), + MongoDB: getEnv("MONGO_DB", "spsays"), + + PostgresURI: getEnv("POSTGRES_URI", ""), + + MeilisearchHost: getEnv("MEILISEARCH_HOST", "http://localhost:7700"), + MeilisearchAPIKey: getEnv("MEILISEARCH_API_KEY", ""), + + RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), + + S3Region: getEnv("S3_REGION", ""), + S3Bucket: getEnv("S3_BUCKET", ""), + S3AccessKey: getEnv("S3_ACCESS_KEY", ""), + S3SecretKey: getEnv("S3_SECRET_KEY", ""), + S3Endpoint: getEnv("S3_ENDPOINT", ""), + + FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), + APNsKeyFile: getEnv("APNS_KEY_FILE", ""), + APNsKeyID: getEnv("APNS_KEY_ID", ""), + APNsTeamID: getEnv("APNS_TEAM_ID", ""), + APNsBundleID: getEnv("APNS_BUNDLE_ID", ""), + + JWTSecret: getEnv("JWT_SECRET", ""), + JWTExpiration: getEnv("JWT_EXPIRATION", "24h"), + + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + } + + // Validate required fields + if cfg.MongoURI == "" { + return nil, fmt.Errorf("MONGO_URI is required") + } + + return cfg, nil +} + +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 != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return fallback +} diff --git a/internal/handler/book.go b/internal/handler/book.go new file mode 100644 index 0000000..9397bf2 --- /dev/null +++ b/internal/handler/book.go @@ -0,0 +1,633 @@ +package handler + +import ( + "math" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type BookHandler struct { + repo *repository.BookRepo +} + +func NewBookHandler(repo *repository.BookRepo) *BookHandler { + return &BookHandler{repo: repo} +} + +// --------------------------------------------------------------------------- +// GET /books — list all books (lightweight summaries only) +// --------------------------------------------------------------------------- + +func (h *BookHandler) ListBooks(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAllBookMetas(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch books") + return + } + response.JSON(w, http.StatusOK, docs) +} + +// --------------------------------------------------------------------------- +// GET /books/{code} — full book metadata (chapters, cantos, līlās, etc.) +// --------------------------------------------------------------------------- + +func (h *BookHandler) GetBook(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + doc, err := h.repo.GetBookMeta(r.Context(), code) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch book") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "book not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// --------------------------------------------------------------------------- +// VERSE BOOKS WITHOUT DIVISIONS (BG, SB1962, BS, MM, NBS) +// +// GET /books/{code}/{chapter} → verse index for sidebar +// GET /books/{code}/{chapter}/{verse} → single verse with full content +// GET /books/{code}/{chapter}/verses → paginated verses for the chapter +// --------------------------------------------------------------------------- + +// GetChapterVerseIndex returns the verse index for a chapter (for sidebar). +// GET /books/{code}/{chapter} +func (h *BookHandler) GetChapterVerseIndex(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + chapterStr := chi.URLParam(r, "chapter") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + + verses, err := h.repo.GetVerseIndex(r.Context(), code, nil, chapter) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter info") + return + } + + response.JSON(w, http.StatusOK, map[string]interface{}{ + "book": code, + "chapter": chapter, + "total_verses": len(verses), + "verses": verses, + }) +} + +// GetVerse returns a single verse with full content. +// GET /books/{code}/{chapter}/{verse} +func (h *BookHandler) GetVerse(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + chapterStr := chi.URLParam(r, "chapter") + verseStr := chi.URLParam(r, "verse") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + verse, err := strconv.Atoi(verseStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid verse number") + return + } + + doc, err := h.repo.GetSingleVerse(r.Context(), code, nil, chapter, verse) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch verse") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "verse not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// GetChapterVerses returns paginated verses for a chapter. +// GET /books/{code}/{chapter}/verses?page=&limit= +func (h *BookHandler) GetChapterVerses(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + chapterStr := chi.URLParam(r, "chapter") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + + page, limit := parsePagination(r) + + docs, total, err := h.repo.GetVerses(r.Context(), code, nil, chapter, page, limit) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch verses") + return + } + + totalPages := int(math.Ceil(float64(total) / float64(limit))) + response.JSONWithMeta(w, http.StatusOK, docs, &response.Meta{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + }) +} + +// --------------------------------------------------------------------------- +// VERSE BOOKS WITH DIVISIONS +// +// SB: division = canto → /books/sb/{canto}/{chapter}/{verse} +// CC: division = līlā → /books/cc/{lila}/{chapter}/{verse} +// +// GET /books/{code}/{division} → chapter list for that division +// GET /books/{code}/{division}/{chapter} → verse index for sidebar +// GET /books/{code}/{division}/{chapter}/{verse} → single verse +// GET /books/{code}/{division}/{chapter}/verses → paginated verses +// --------------------------------------------------------------------------- + +// GetDivisionInfo returns the chapter listing for a specific division (canto/līlā). +// It extracts the relevant division from the book_meta data blob. +// GET /books/sb/{division} or /books/cc/{division} +func (h *BookHandler) GetDivisionInfo(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + division := chi.URLParam(r, "division") + + doc, err := h.repo.GetBookMeta(r.Context(), code) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch book") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "book not found") + return + } + + // Extract the division's chapter list from the data blob. + // SB data: { cantos: [{ number, title, totalChapters, chapters: [{number, title, totalVerses}] }] } + // CC data: { lilas: [{ key, name, totalChapters, chapters: [{number, title, totalVerses}] }] } + result := extractDivisionFromMeta(code, division, doc.Data) + if result == nil { + response.Error(w, http.StatusNotFound, "division not found") + return + } + + response.JSON(w, http.StatusOK, result) +} + +// GetDivisionChapterVerseIndex returns the verse index for a chapter within a division. +// GET /books/{code}/{division}/{chapter} +func (h *BookHandler) GetDivisionChapterVerseIndex(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + division := chi.URLParam(r, "division") + chapterStr := chi.URLParam(r, "chapter") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + + verses, err := h.repo.GetVerseIndex(r.Context(), code, &division, chapter) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter info") + return + } + + response.JSON(w, http.StatusOK, map[string]interface{}{ + "book": code, + "division": division, + "chapter": chapter, + "total_verses": len(verses), + "verses": verses, + }) +} + +// GetDivisionVerse returns a single verse within a division. +// GET /books/{code}/{division}/{chapter}/{verse} +func (h *BookHandler) GetDivisionVerse(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + division := chi.URLParam(r, "division") + chapterStr := chi.URLParam(r, "chapter") + verseStr := chi.URLParam(r, "verse") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + verse, err := strconv.Atoi(verseStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid verse number") + return + } + + doc, err := h.repo.GetSingleVerse(r.Context(), code, &division, chapter, verse) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch verse") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "verse not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// GetDivisionChapterVerses returns paginated verses within a division. +// GET /books/{code}/{division}/{chapter}/verses?page=&limit= +func (h *BookHandler) GetDivisionChapterVerses(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + division := chi.URLParam(r, "division") + chapterStr := chi.URLParam(r, "chapter") + + chapter, err := strconv.Atoi(chapterStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid chapter number") + return + } + + page, limit := parsePagination(r) + + docs, total, err := h.repo.GetVerses(r.Context(), code, &division, chapter, page, limit) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch verses") + return + } + + totalPages := int(math.Ceil(float64(total) / float64(limit))) + response.JSONWithMeta(w, http.StatusOK, docs, &response.Meta{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + }) +} + +// --------------------------------------------------------------------------- +// PROSE BOOKS — simple chapters (BBD, EJ, EK, MG, NOD, TLC, SSR, DS, etc.) +// +// GET /books/{code}/chapters → chapter list (sidebar) +// GET /books/{code}/chapters/{slug} → single chapter content +// --------------------------------------------------------------------------- + +// ListProseChapters returns chapter listing for a prose book. +// GET /books/{code}/chapters?volume=&type= +// +// For books with verses (ISO, BS, NOI), also appends verse entries +// (mantras/texts) from the book metadata so the sidebar is complete. +func (h *BookHandler) ListProseChapters(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + volumeStr := r.URL.Query().Get("volume") + chapterType := r.URL.Query().Get("type") + + volume := 0 + if v, e := strconv.Atoi(volumeStr); e == nil { + volume = v + } + + var typePtr *string + if chapterType != "" { + typePtr = &chapterType + } + + docs, err := h.repo.GetProseChapters(r.Context(), code, volume, typePtr) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapters") + return + } + + // Strip paragraphs from list response — only return sidebar info + type ChapterSummary struct { + ID interface{} `json:"id"` + Book string `json:"book"` + Volume int `json:"volume"` + Chapter *int `json:"chapter,omitempty"` + Title string `json:"title"` + Type string `json:"type"` + Slug string `json:"slug"` + Order int `json:"order"` + Mantra *int `json:"mantra,omitempty"` + Text *int `json:"text,omitempty"` + } + summaries := make([]ChapterSummary, 0, len(docs)) + for _, d := range docs { + summaries = append(summaries, ChapterSummary{ + ID: d.ID, + Book: d.Book, + Volume: d.Volume, + Chapter: d.Chapter, + Title: d.Title, + Type: d.Type, + Slug: d.Slug, + Order: d.Order, + }) + } + + // For verse+prose books (ISO, BS, NOI), append verse entries from book meta + // so the sidebar includes mantras/texts alongside intro segments. + if chapterType == "" || chapterType == "verse" { + meta, metaErr := h.repo.GetBookMeta(r.Context(), code) + if metaErr == nil && meta != nil { + if dataMap, ok := meta.Data.(bson.D); ok { + for _, elem := range dataMap { + if elem.Key == "mantras" { + // ISO: mantras array with {number, slug, title} + if arr, ok := elem.Value.(bson.A); ok { + baseOrder := len(summaries) + 1 + for i, item := range arr { + if m, ok := item.(bson.D); ok { + num := 0 + slug := "" + title := "" + for _, f := range m { + switch f.Key { + case "number": + if n, ok := f.Value.(int32); ok { + num = int(n) + } else if n, ok := f.Value.(int64); ok { + num = int(n) + } + case "slug": + if s, ok := f.Value.(string); ok { + slug = s + } + case "title": + if s, ok := f.Value.(string); ok { + title = s + } + } + } + if slug == "" { + slug = strconv.Itoa(num) + } + mantraNum := num + summaries = append(summaries, ChapterSummary{ + Book: code, + Title: title, + Type: "verse", + Slug: slug, + Order: baseOrder + i, + Mantra: &mantraNum, + }) + } + } + } + } else if elem.Key == "texts" { + // BS, NOI: texts array with {number, slug, title} + if arr, ok := elem.Value.(bson.A); ok { + baseOrder := len(summaries) + 1 + for i, item := range arr { + if m, ok := item.(bson.D); ok { + num := 0 + slug := "" + title := "" + for _, f := range m { + switch f.Key { + case "number": + if n, ok := f.Value.(int32); ok { + num = int(n) + } else if n, ok := f.Value.(int64); ok { + num = int(n) + } + case "slug": + if s, ok := f.Value.(string); ok { + slug = s + } + case "title": + if s, ok := f.Value.(string); ok { + title = s + } + } + } + if slug == "" { + slug = strconv.Itoa(num) + } + textNum := num + summaries = append(summaries, ChapterSummary{ + Book: code, + Title: title, + Type: "verse", + Slug: slug, + Order: baseOrder + i, + Text: &textNum, + }) + } + } + } + } + } + } + } + } + + response.JSON(w, http.StatusOK, summaries) +} + +// GetProseChapter returns a single prose chapter with full content. +// GET /books/{code}/chapters/{slug}?volume= +func (h *BookHandler) GetProseChapter(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + slug := chi.URLParam(r, "slug") + volumeStr := r.URL.Query().Get("volume") + + volume := 0 + if v, e := strconv.Atoi(volumeStr); e == nil { + volume = v + } + + doc, err := h.repo.GetProseChapter(r.Context(), code, volume, slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "chapter not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// --------------------------------------------------------------------------- +// VOLUME-BASED PROSE BOOKS (KB) +// +// GET /books/{code}/volumes/{volume} → chapter list for volume +// GET /books/{code}/volumes/{volume}/{slug} → single chapter content +// --------------------------------------------------------------------------- + +// ListVolumeChapters returns chapters for a specific volume. +// GET /books/{code}/volumes/{volume} +func (h *BookHandler) ListVolumeChapters(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + volumeStr := chi.URLParam(r, "volume") + + volume, err := strconv.Atoi(volumeStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid volume number") + return + } + + docs, err := h.repo.GetProseChapters(r.Context(), code, volume, nil) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapters") + return + } + + // Strip paragraphs from list response + type ChapterSummary struct { + ID interface{} `json:"id"` + Book string `json:"book"` + Volume int `json:"volume"` + Chapter *int `json:"chapter,omitempty"` + Title string `json:"title"` + Type string `json:"type"` + Slug string `json:"slug"` + Order int `json:"order"` + } + summaries := make([]ChapterSummary, len(docs)) + for i, d := range docs { + summaries[i] = ChapterSummary{ + ID: d.ID, + Book: d.Book, + Volume: d.Volume, + Chapter: d.Chapter, + Title: d.Title, + Type: d.Type, + Slug: d.Slug, + Order: d.Order, + } + } + response.JSON(w, http.StatusOK, summaries) +} + +// GetVolumeChapter returns a single chapter within a volume. +// GET /books/{code}/volumes/{volume}/{slug} +func (h *BookHandler) GetVolumeChapter(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + volumeStr := chi.URLParam(r, "volume") + slug := chi.URLParam(r, "slug") + + volume, err := strconv.Atoi(volumeStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid volume number") + return + } + + doc, err := h.repo.GetProseChapter(r.Context(), code, volume, slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "chapter not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func parsePagination(r *http.Request) (page, limit int) { + page = 1 + limit = 20 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { + limit = l + } + return +} + +// extractDivisionFromMeta pulls the relevant division (canto/līlā) from the +// book_meta data blob and returns it as a map, or nil if not found. +func extractDivisionFromMeta(code, division string, data interface{}) map[string]interface{} { + dataMap, ok := data.(bson.D) + if !ok { + return nil + } + + divNum, numErr := strconv.Atoi(division) + + switch code { + case "sb": + // SB: data.cantos is an array, division is canto number (1-indexed) + for _, elem := range dataMap { + if elem.Key != "cantos" { + continue + } + cantos, ok := elem.Value.(bson.A) + if !ok { + return nil + } + if numErr != nil || divNum < 1 || divNum > len(cantos) { + return nil + } + canto, ok := cantos[divNum-1].(bson.D) + if !ok { + return nil + } + return bsonDToMap(canto) + } + + case "cc": + // CC: data.lilas is an array, division is līlā key ("adi", "madhya", "antya") + for _, elem := range dataMap { + if elem.Key != "lilas" { + continue + } + lilas, ok := elem.Value.(bson.A) + if !ok { + return nil + } + for _, l := range lilas { + lila, ok := l.(bson.D) + if !ok { + continue + } + for _, field := range lila { + if field.Key == "key" && field.Value == division { + return bsonDToMap(lila) + } + } + } + } + } + + return nil +} + +// bsonDToMap converts a bson.D to a map[string]interface{} for JSON serialization. +func bsonDToMap(d bson.D) map[string]interface{} { + m := make(map[string]interface{}, len(d)) + for _, elem := range d { + switch v := elem.Value.(type) { + case bson.D: + m[elem.Key] = bsonDToMap(v) + case bson.A: + arr := make([]interface{}, len(v)) + for i, item := range v { + if sub, ok := item.(bson.D); ok { + arr[i] = bsonDToMap(sub) + } else { + arr[i] = item + } + } + m[elem.Key] = arr + default: + m[elem.Key] = v + } + } + return m +} diff --git a/internal/handler/disciple.go b/internal/handler/disciple.go new file mode 100644 index 0000000..b0ca2ce --- /dev/null +++ b/internal/handler/disciple.go @@ -0,0 +1,93 @@ +package handler + +import ( + "net/http" + + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type DiscipleHandler struct { + repo *repository.DiscipleRepo +} + +func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler { + return &DiscipleHandler{repo: repo} +} + +func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) { + letter := r.URL.Query().Get("letter") + suffix := r.URL.Query().Get("suffix") + + var letterPtr, suffixPtr *string + if letter != "" && letter != "All" { + letterPtr = &letter + } + if suffix != "" { + suffixPtr = &suffix + } + + docs, err := h.repo.GetByLetterAndSuffix(r.Context(), letterPtr, suffixPtr) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch disciples") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *DiscipleHandler) Count(w http.ResponseWriter, r *http.Request) { + letter := r.URL.Query().Get("letter") + suffix := r.URL.Query().Get("suffix") + + var letterPtr, suffixPtr *string + if letter != "" && letter != "All" { + letterPtr = &letter + } + if suffix != "" { + suffixPtr = &suffix + } + + total, err := h.repo.Count(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to count disciples") + return + } + filtered, err := h.repo.CountFiltered(r.Context(), letterPtr, suffixPtr) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to count filtered disciples") + return + } + response.JSON(w, http.StatusOK, map[string]int64{"total": total, "filtered": filtered}) +} + +func (h *DiscipleHandler) LetterCounts(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetLetterCounts(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letter counts") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *DiscipleHandler) SuffixCounts(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetSuffixCounts(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch suffix counts") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *DiscipleHandler) Search(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + response.Error(w, http.StatusBadRequest, "search query is required") + return + } + docs, err := h.repo.Search(r.Context(), query, 100) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to search disciples") + return + } + response.JSON(w, http.StatusOK, docs) +} diff --git a/internal/handler/essay.go b/internal/handler/essay.go new file mode 100644 index 0000000..0321639 --- /dev/null +++ b/internal/handler/essay.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type EssayHandler struct { + repo *repository.EssayRepo +} + +func NewEssayHandler(repo *repository.EssayRepo) *EssayHandler { + return &EssayHandler{repo: repo} +} + +func (h *EssayHandler) List(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAll(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch essays") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *EssayHandler) GetBySlug(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetBySlug(r.Context(), slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch essay") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "essay not found") + return + } + response.JSON(w, http.StatusOK, doc) +} diff --git a/internal/handler/glossary.go b/internal/handler/glossary.go new file mode 100644 index 0000000..c841b1c --- /dev/null +++ b/internal/handler/glossary.go @@ -0,0 +1,97 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type GlossaryHandler struct { + repo *repository.GlossaryRepo +} + +func NewGlossaryHandler(repo *repository.GlossaryRepo) *GlossaryHandler { + return &GlossaryHandler{repo: repo} +} + +// List returns all glossary entries sorted by term. +// GET /glossary +func (h *GlossaryHandler) List(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAll(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch glossary") + return + } + response.JSON(w, http.StatusOK, docs) +} + +// GetByTerm returns a single glossary entry by exact term match. +// GET /glossary/term/{term} +func (h *GlossaryHandler) GetByTerm(w http.ResponseWriter, r *http.Request) { + term := chi.URLParam(r, "term") + doc, err := h.repo.GetByTerm(r.Context(), term) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch term") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "term not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +// GetByLetter returns all glossary entries whose term starts with the given +// base letter (including diacritical variants like Ā→A, Ś→S, etc.). +// GET /glossary/letter/{letter} +func (h *GlossaryHandler) GetByLetter(w http.ResponseWriter, r *http.Request) { + letter := chi.URLParam(r, "letter") + if len(letter) != 1 { + response.Error(w, http.StatusBadRequest, "letter must be a single character") + return + } + + docs, err := h.repo.GetByLetter(r.Context(), letter) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch glossary entries") + return + } + if docs == nil { + docs = []repository.GlossaryEntryDoc{} + } + response.JSON(w, http.StatusOK, docs) +} + +// GetTermsByLetter returns lightweight term summaries (no definitions) for a +// given base letter. Used for sidebar listing. +// GET /glossary/letter/{letter}/terms +func (h *GlossaryHandler) GetTermsByLetter(w http.ResponseWriter, r *http.Request) { + letter := chi.URLParam(r, "letter") + if len(letter) != 1 { + response.Error(w, http.StatusBadRequest, "letter must be a single character") + return + } + + docs, err := h.repo.GetTermsByLetter(r.Context(), letter) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch terms") + return + } + if docs == nil { + docs = []repository.GlossaryTermSummary{} + } + response.JSON(w, http.StatusOK, docs) +} + +// GetLetterCounts returns the count of glossary entries per base letter. +// GET /glossary/letters +func (h *GlossaryHandler) GetLetterCounts(w http.ResponseWriter, r *http.Request) { + counts, err := h.repo.GetLetterCounts(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letter counts") + return + } + response.JSON(w, http.StatusOK, counts) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..9c76a7e --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// Health handles the health check endpoint. +type Health struct{} + +// NewHealth creates a new Health handler. +func NewHealth() *Health { + return &Health{} +} + +// Check returns the server health status. +func (h *Health) Check(w http.ResponseWriter, r *http.Request) { + response.JSON(w, http.StatusOK, map[string]string{ + "status": "ok", + }) +} diff --git a/internal/handler/journal.go b/internal/handler/journal.go new file mode 100644 index 0000000..e79566b --- /dev/null +++ b/internal/handler/journal.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type JournalHandler struct { + repo *repository.JournalRepo +} + +func NewJournalHandler(repo *repository.JournalRepo) *JournalHandler { + return &JournalHandler{repo: repo} +} + +func (h *JournalHandler) ListJournals(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAllMetas(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch journals") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *JournalHandler) GetJournal(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + doc, err := h.repo.GetMeta(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch journal") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "journal not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +func (h *JournalHandler) ListChapters(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + docs, err := h.repo.GetChapters(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapters") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *JournalHandler) GetChapter(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetChapter(r.Context(), book, slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "chapter not found") + return + } + response.JSON(w, http.StatusOK, doc) +} diff --git a/internal/handler/letter.go b/internal/handler/letter.go new file mode 100644 index 0000000..f6ff2c3 --- /dev/null +++ b/internal/handler/letter.go @@ -0,0 +1,103 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type LetterHandler struct { + repo *repository.LetterRepo +} + +func NewLetterHandler(repo *repository.LetterRepo) *LetterHandler { + return &LetterHandler{repo: repo} +} + +func (h *LetterHandler) GetBySlug(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetBySlug(r.Context(), slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letter") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "letter not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +func (h *LetterHandler) GetByYear(w http.ResponseWriter, r *http.Request) { + yearStr := chi.URLParam(r, "year") + year, err := strconv.Atoi(yearStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid year") + return + } + docs, err := h.repo.GetByYear(r.Context(), year) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letters") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) GetByRecipient(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + docs, err := h.repo.GetByRecipient(r.Context(), name) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letters") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) GetByLocation(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + docs, err := h.repo.GetByLocation(r.Context(), name) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch letters") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) GetYears(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetYears(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch years") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) GetRecipients(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetRecipients(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch recipients") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) GetLocations(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetLocations(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch locations") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *LetterHandler) Count(w http.ResponseWriter, r *http.Request) { + count, err := h.repo.Count(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to count letters") + return + } + response.JSON(w, http.StatusOK, map[string]int64{"count": count}) +} diff --git a/internal/handler/magazine.go b/internal/handler/magazine.go new file mode 100644 index 0000000..d612dcd --- /dev/null +++ b/internal/handler/magazine.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type MagazineHandler struct { + repo *repository.MagazineRepo +} + +func NewMagazineHandler(repo *repository.MagazineRepo) *MagazineHandler { + return &MagazineHandler{repo: repo} +} + +func (h *MagazineHandler) ListMagazines(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAllMetas(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch magazines") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *MagazineHandler) GetMagazine(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + doc, err := h.repo.GetMeta(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch magazine") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "magazine not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +func (h *MagazineHandler) ListIssues(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + docs, err := h.repo.GetIssues(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch issues") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *MagazineHandler) GetIssue(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetIssue(r.Context(), book, slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch issue") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "issue not found") + return + } + response.JSON(w, http.StatusOK, doc) +} diff --git a/internal/handler/other_document.go b/internal/handler/other_document.go new file mode 100644 index 0000000..9d09228 --- /dev/null +++ b/internal/handler/other_document.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type OtherDocumentHandler struct { + repo *repository.OtherDocumentRepo +} + +func NewOtherDocumentHandler(repo *repository.OtherDocumentRepo) *OtherDocumentHandler { + return &OtherDocumentHandler{repo: repo} +} + +func (h *OtherDocumentHandler) ListDocuments(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetAllMetas(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch documents") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *OtherDocumentHandler) GetDocument(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + doc, err := h.repo.GetMeta(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch document") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "document not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +func (h *OtherDocumentHandler) ListChapters(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + docs, err := h.repo.GetChapters(r.Context(), book) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapters") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *OtherDocumentHandler) GetChapter(w http.ResponseWriter, r *http.Request) { + book := chi.URLParam(r, "book") + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetChapter(r.Context(), book, slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch chapter") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "chapter not found") + return + } + response.JSON(w, http.StatusOK, doc) +} diff --git a/internal/handler/transcription.go b/internal/handler/transcription.go new file mode 100644 index 0000000..0f4228b --- /dev/null +++ b/internal/handler/transcription.go @@ -0,0 +1,129 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type TranscriptionHandler struct { + repo *repository.TranscriptionRepo +} + +func NewTranscriptionHandler(repo *repository.TranscriptionRepo) *TranscriptionHandler { + return &TranscriptionHandler{repo: repo} +} + +func (h *TranscriptionHandler) GetBySlug(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + doc, err := h.repo.GetBySlug(r.Context(), slug) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch transcription") + return + } + if doc == nil { + response.Error(w, http.StatusNotFound, "transcription not found") + return + } + response.JSON(w, http.StatusOK, doc) +} + +func (h *TranscriptionHandler) GetByYear(w http.ResponseWriter, r *http.Request) { + yearStr := chi.URLParam(r, "year") + year, err := strconv.Atoi(yearStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid year") + return + } + docs, err := h.repo.GetByYear(r.Context(), year) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *TranscriptionHandler) GetByPlace(w http.ResponseWriter, r *http.Request) { + place := chi.URLParam(r, "place") + docs, err := h.repo.GetByPlace(r.Context(), place) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *TranscriptionHandler) GetByType(w http.ResponseWriter, r *http.Request) { + docType := chi.URLParam(r, "type") + if docType != "lecture" && docType != "conversation" { + response.Error(w, http.StatusBadRequest, "invalid type: must be 'lecture' or 'conversation'") + return + } + docs, err := h.repo.GetByType(r.Context(), docType) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *TranscriptionHandler) GetYears(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetYears(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch years") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *TranscriptionHandler) GetPlaces(w http.ResponseWriter, r *http.Request) { + docs, err := h.repo.GetPlaces(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch places") + return + } + response.JSON(w, http.StatusOK, docs) +} + +func (h *TranscriptionHandler) GetTypeDistribution(w http.ResponseWriter, r *http.Request) { + lectures, conversations, err := h.repo.GetTypeDistribution(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch type distribution") + return + } + response.JSON(w, http.StatusOK, map[string]int64{ + "lectures": lectures, + "conversations": conversations, + }) +} + +func (h *TranscriptionHandler) Count(w http.ResponseWriter, r *http.Request) { + count, err := h.repo.Count(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to count transcriptions") + return + } + response.JSON(w, http.StatusOK, map[string]int64{"count": count}) +} + +func (h *TranscriptionHandler) GetAdjacent(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + yearStr := r.URL.Query().Get("year") + year, err := strconv.Atoi(yearStr) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid year") + return + } + prev, next, err := h.repo.GetAdjacent(r.Context(), slug, year) + if err != nil { + response.Error(w, http.StatusInternalServerError, "failed to fetch adjacent transcriptions") + return + } + response.JSON(w, http.StatusOK, map[string]interface{}{ + "prev": prev, + "next": next, + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..5adaea6 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +type contextKey string + +const UserIDKey contextKey = "user_id" + +// Auth is a placeholder middleware for JWT authentication. +// It extracts the Bearer token from the Authorization header, +// validates it, and sets the user ID in the request context. +func Auth(jwtSecret string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + response.Error(w, http.StatusUnauthorized, "missing authorization header") + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + response.Error(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + token := parts[1] + + // TODO: validate JWT token and extract user ID + _ = token + _ = jwtSecret + userID := "" // placeholder + + if userID == "" { + response.Error(w, http.StatusUnauthorized, "invalid or expired token") + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetUserID extracts the user ID from the request context. +func GetUserID(ctx context.Context) string { + if v, ok := ctx.Value(UserIDKey).(string); ok { + return v + } + return "" +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..948ec62 --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "net/http" + "time" + + chiMiddleware "github.com/go-chi/chi/v5/middleware" + "go.uber.org/zap" +) + +// Logger returns a chi-compatible middleware that logs requests using zap. +func Logger(logger *zap.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ww := chiMiddleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + + logger.Info("request", + zap.String("method", r.Method), + zap.String("path", r.URL.Path), + zap.String("query", r.URL.RawQuery), + zap.Int("status", ww.Status()), + zap.Int("bytes", ww.BytesWritten()), + zap.Duration("duration", time.Since(start)), + zap.String("ip", r.RemoteAddr), + zap.String("request_id", chiMiddleware.GetReqID(r.Context())), + ) + }) + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..2e0bbad --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// RateLimiter is a simple in-memory token bucket rate limiter. +// In production, replace with DragonflyDB/Redis-based limiter. +type RateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate int // tokens per interval + interval time.Duration // refill interval +} + +type visitor struct { + tokens int + lastSeen time.Time +} + +// NewRateLimiter creates a new in-memory rate limiter. +func NewRateLimiter(rate int, interval time.Duration) *RateLimiter { + rl := &RateLimiter{ + visitors: make(map[string]*visitor), + rate: rate, + interval: interval, + } + // Clean up stale visitors every minute + go rl.cleanup() + return rl +} + +// Limit returns a middleware that rate-limits requests by IP. +func (rl *RateLimiter) Limit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + + rl.mu.Lock() + v, exists := rl.visitors[ip] + if !exists { + v = &visitor{tokens: rl.rate, lastSeen: time.Now()} + rl.visitors[ip] = v + } + + // Refill tokens + elapsed := time.Since(v.lastSeen) + refill := int(elapsed / rl.interval) + if refill > 0 { + v.tokens = min(v.tokens+refill*rl.rate, rl.rate) + v.lastSeen = time.Now() + } + + if v.tokens <= 0 { + rl.mu.Unlock() + response.Error(w, http.StatusTooManyRequests, "rate limit exceeded") + return + } + + v.tokens-- + rl.mu.Unlock() + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) cleanup() { + for { + time.Sleep(time.Minute) + rl.mu.Lock() + for ip, v := range rl.visitors { + if time.Since(v.lastSeen) > 3*time.Minute { + delete(rl.visitors, ip) + } + } + rl.mu.Unlock() + } +} diff --git a/internal/model/content.go b/internal/model/content.go new file mode 100644 index 0000000..455f69d --- /dev/null +++ b/internal/model/content.go @@ -0,0 +1,116 @@ +package model + +import "time" + +// Book represents a book's metadata from MongoDB. +type Book struct { + ID string `json:"id"` + Book string `json:"book"` + Data map[string]interface{} `json:"data"` +} + +// Verse represents a single verse from MongoDB. +type Verse struct { + ID string `json:"id"` + Book string `json:"book"` + Division *string `json:"division,omitempty"` + Chapter int `json:"chapter"` + Verse int `json:"verse"` + VerseEnd *int `json:"verse_end,omitempty"` + Reference string `json:"reference"` + BookTitle string `json:"book_title"` + Devanagari string `json:"devanagari"` + Bengali *string `json:"bengali,omitempty"` + Transliteration string `json:"transliteration"` + Synonyms []Synonym `json:"synonyms"` + Translation string `json:"translation"` + Purport []string `json:"purport"` +} + +// Synonym represents a word-meaning pair in verse synonyms. +type Synonym struct { + Word string `json:"word"` + Meanings []string `json:"meanings"` +} + +// Letter represents a letter from MongoDB. +type Letter struct { + ID string `json:"id"` + Slug string `json:"slug"` + Recipients []string `json:"recipients"` + RecipientDisplay string `json:"recipient_display"` + Date *string `json:"date,omitempty"` + DateRef *string `json:"date_ref,omitempty"` + Year *int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + Day *int `json:"day,omitempty"` + Location *string `json:"location,omitempty"` + Salutation *string `json:"salutation,omitempty"` + Paragraphs []string `json:"paragraphs"` +} + +// Essay represents an essay from MongoDB. +type Essay struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Year *int `json:"year,omitempty"` + Group *string `json:"group,omitempty"` + Order int `json:"order"` + Paragraphs []string `json:"paragraphs"` +} + +// GlossaryEntry represents a glossary term from MongoDB. +type GlossaryEntry struct { + ID string `json:"id"` + Term string `json:"term"` + Definition string `json:"definition"` + DefinitionHTML string `json:"definition_html"` +} + +// Disciple represents a disciple from MongoDB. +type Disciple struct { + ID string `json:"id"` + InitiatedName string `json:"initiated_name"` + Suffix string `json:"suffix"` + InitiationDate *time.Time `json:"initiation_date,omitempty"` + InitiationPlace *string `json:"initiation_place,omitempty"` + KarmiName *string `json:"karmi_name,omitempty"` + Avatar *string `json:"avatar,omitempty"` + About *string `json:"about,omitempty"` + Tags []string `json:"tags"` + IsVerified bool `json:"is_verified"` +} + +// Transcription represents a lecture/conversation from MongoDB. +type Transcription struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Type string `json:"type"` // "lecture" | "conversation" + Date *string `json:"date,omitempty"` + Year *int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + Day *int `json:"day,omitempty"` + Place *string `json:"place,omitempty"` + Context *string `json:"context,omitempty"` + Reference *string `json:"reference,omitempty"` + Summary *string `json:"summary,omitempty"` + Content []ContentBlock `json:"content"` + Tags []string `json:"tags"` + Participants []string `json:"participants"` + Language string `json:"language"` + Source *string `json:"source,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + AudioUrl *string `json:"audio_url,omitempty"` + IsPublished bool `json:"is_published"` +} + +// ContentBlock represents a rich text block within a transcription. +type ContentBlock struct { + Text string `json:"text"` + Format string `json:"format"` // "paragraph" | "heading" | "quote" | "verse" | "note" + Alignment string `json:"alignment"` // "left" | "center" | "right" + Speaker *string `json:"speaker,omitempty"` +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..1831d97 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,60 @@ +package model + +import "time" + +// User represents a user in PostgreSQL. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Avatar *string `json:"avatar,omitempty"` + Role string `json:"role"` // "user" | "admin" | "moderator" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Bookmark represents a user's bookmarked content. +type Bookmark struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc. + ContentID string `json:"content_id"` + ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1") + SelectedText *string `json:"selected_text,omitempty"` + Note *string `json:"note,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Collection represents a user-created collection. +type Collection struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + IsPublic bool `json:"is_public"` + ItemCount int `json:"item_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Mention represents a mention of content shared by a user. +type Mention struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ContentType string `json:"content_type"` + ContentID string `json:"content_id"` + ContentRef string `json:"content_ref"` + Text string `json:"text"` + CreatedAt time.Time `json:"created_at"` +} + +// ShortURL represents a shortened URL. +type ShortURL struct { + ID string `json:"id"` + Slug string `json:"slug"` + TargetURL string `json:"target_url"` + CreatedBy *string `json:"created_by,omitempty"` + ClickCount int64 `json:"click_count"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} diff --git a/internal/repository/.gitkeep b/internal/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/repository/book_repo.go b/internal/repository/book_repo.go new file mode 100644 index 0000000..81cce86 --- /dev/null +++ b/internal/repository/book_repo.go @@ -0,0 +1,211 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type BookRepo struct { + meta *mongo.Collection + verses *mongo.Collection + proseChapters *mongo.Collection +} + +func NewBookRepo(db *MongoDB) *BookRepo { + return &BookRepo{ + meta: db.Collection("book_meta"), + verses: db.Collection("verses"), + proseChapters: db.Collection("prose_chapters"), + } +} + +// --- Book Meta --- + +type BookMetaDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Data interface{} `bson:"data" json:"data"` +} + +func (r *BookRepo) GetAllBookMetas(ctx context.Context) ([]BookMetaDoc, error) { + cursor, err := r.meta.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var results []BookMetaDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + + return results, nil +} + +func (r *BookRepo) GetBookMeta(ctx context.Context, book string) (*BookMetaDoc, error) { + var doc BookMetaDoc + err := r.meta.FindOne(ctx, bson.M{"book": book}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +// --- Verses --- + +type SynonymDoc struct { + Word string `bson:"word" json:"word"` + Meanings []string `bson:"meanings" json:"meanings"` +} + +type VerseDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Division *string `bson:"division,omitempty" json:"division,omitempty"` + Chapter int `bson:"chapter" json:"chapter"` + Verse int `bson:"verse" json:"verse"` + VerseEnd *int `bson:"verseEnd,omitempty" json:"verse_end,omitempty"` + Reference string `bson:"reference" json:"reference"` + BookTitle string `bson:"bookTitle" json:"book_title"` + Devanagari string `bson:"devanagari" json:"devanagari"` + Bengali *string `bson:"bengali,omitempty" json:"bengali,omitempty"` + Transliteration string `bson:"transliteration" json:"transliteration"` + Synonyms []SynonymDoc `bson:"synonyms" json:"synonyms"` + Translation string `bson:"translation" json:"translation"` + Purport []string `bson:"purport" json:"purport"` +} + +type VerseIndexDoc struct { + Verse int `bson:"verse" json:"verse"` + VerseEnd *int `bson:"verseEnd,omitempty" json:"verse_end,omitempty"` + Reference string `bson:"reference" json:"reference"` +} + +func (r *BookRepo) GetVerseIndex(ctx context.Context, book string, division *string, chapter int) ([]VerseIndexDoc, error) { + filter := bson.M{"book": book, "chapter": chapter} + if division != nil { + filter["division"] = *division + } else { + filter["division"] = nil + } + + opts := options.Find(). + SetSort(bson.M{"verse": 1}). + SetProjection(bson.M{"_id": 0, "verse": 1, "verseEnd": 1, "reference": 1}) + + cursor, err := r.verses.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var results []VerseIndexDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *BookRepo) GetVerses(ctx context.Context, book string, division *string, chapter int, page, limit int) ([]VerseDoc, int64, error) { + filter := bson.M{"book": book, "chapter": chapter} + if division != nil { + filter["division"] = *division + } else { + filter["division"] = nil + } + + total, err := r.verses.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + skip := int64((page - 1) * limit) + opts := options.Find(). + SetSort(bson.M{"verse": 1}). + SetSkip(skip). + SetLimit(int64(limit)) + + cursor, err := r.verses.Find(ctx, filter, opts) + if err != nil { + return nil, 0, err + } + defer cursor.Close(ctx) + + var results []VerseDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, 0, err + } + return results, total, nil +} + +func (r *BookRepo) GetSingleVerse(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) { + filter := bson.M{"book": book, "chapter": chapter, "verse": verse} + if division != nil { + filter["division"] = *division + } else { + filter["division"] = nil + } + + var doc VerseDoc + err := r.verses.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +// --- Prose Chapters --- + +type ProseChapterDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Volume int `bson:"volume" json:"volume"` + Chapter *int `bson:"chapter,omitempty" json:"chapter,omitempty"` + Title string `bson:"title" json:"title"` + Type string `bson:"type" json:"type"` + Slug string `bson:"slug" json:"slug"` + Order int `bson:"order" json:"order"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +func (r *BookRepo) GetProseChapters(ctx context.Context, book string, volume int, chapterType *string) ([]ProseChapterDoc, error) { + filter := bson.M{"book": book, "volume": volume} + if chapterType != nil { + filter["type"] = *chapterType + } + + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.proseChapters.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var results []ProseChapterDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *BookRepo) GetProseChapter(ctx context.Context, book string, volume int, slug string) (*ProseChapterDoc, error) { + filter := bson.M{"book": book, "volume": volume, "slug": slug} + var doc ProseChapterDoc + err := r.proseChapters.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} diff --git a/internal/repository/disciple_repo.go b/internal/repository/disciple_repo.go new file mode 100644 index 0000000..7b516d5 --- /dev/null +++ b/internal/repository/disciple_repo.go @@ -0,0 +1,160 @@ +package repository + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type DiscipleRepo struct { + col *mongo.Collection +} + +func NewDiscipleRepo(db *MongoDB) *DiscipleRepo { + return &DiscipleRepo{col: db.Collection("disciples")} +} + +type DiscipleDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + InitiatedName string `bson:"initiatedName" json:"initiated_name"` + Suffix string `bson:"suffix" json:"suffix"` + InitiationDate *time.Time `bson:"initiationDate,omitempty" json:"initiation_date,omitempty"` + InitiationPlace *string `bson:"initiationPlace,omitempty" json:"initiation_place,omitempty"` + KarmiName *string `bson:"karmiName,omitempty" json:"karmi_name,omitempty"` + Avatar *string `bson:"avatar,omitempty" json:"avatar,omitempty"` + About *string `bson:"about,omitempty" json:"about,omitempty"` + Tags []string `bson:"tags" json:"tags"` + IsVerified bool `bson:"isVerified" json:"is_verified"` +} + +type DiscipleSummaryDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + InitiatedName string `bson:"initiatedName" json:"initiated_name"` + Suffix string `bson:"suffix" json:"suffix"` + InitiationDate *time.Time `bson:"initiationDate,omitempty" json:"initiation_date,omitempty"` + InitiationPlace *string `bson:"initiationPlace,omitempty" json:"initiation_place,omitempty"` + IsVerified bool `bson:"isVerified" json:"is_verified"` +} + +var discipleSummaryProjection = bson.M{ + "initiatedName": 1, "suffix": 1, "initiationDate": 1, + "initiationPlace": 1, "isVerified": 1, +} + +func (r *DiscipleRepo) GetByLetter(ctx context.Context, letter string) ([]DiscipleSummaryDoc, error) { + // Case-insensitive starts-with using regex + filter := bson.M{"initiatedName": bson.M{"$regex": "^" + letter, "$options": "i"}} + opts := options.Find(). + SetSort(bson.M{"initiatedName": 1}). + SetProjection(discipleSummaryProjection) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []DiscipleSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *DiscipleRepo) GetByLetterAndSuffix(ctx context.Context, letter *string, suffix *string) ([]DiscipleSummaryDoc, error) { + filter := bson.M{} + if letter != nil { + filter["initiatedName"] = bson.M{"$regex": "^" + *letter, "$options": "i"} + } + if suffix != nil && *suffix != "all" { + filter["suffix"] = *suffix + } + + opts := options.Find(). + SetSort(bson.M{"initiatedName": 1}). + SetProjection(discipleSummaryProjection) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []DiscipleSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) { + return r.col.CountDocuments(ctx, bson.M{}) +} + +func (r *DiscipleRepo) CountFiltered(ctx context.Context, letter *string, suffix *string) (int64, error) { + filter := bson.M{} + if letter != nil { + filter["initiatedName"] = bson.M{"$regex": "^" + *letter, "$options": "i"} + } + if suffix != nil && *suffix != "all" { + filter["suffix"] = *suffix + } + return r.col.CountDocuments(ctx, filter) +} + +func (r *DiscipleRepo) GetLetterCounts(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$group": bson.M{ + "_id": bson.M{"$toUpper": bson.M{"$substrBytes": bson.A{"$initiatedName", 0, 1}}}, + "count": bson.M{"$sum": 1}, + }}, + bson.M{"$sort": bson.M{"_id": 1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *DiscipleRepo) GetSuffixCounts(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$group": bson.M{"_id": "$suffix", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"count": -1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *DiscipleRepo) Search(ctx context.Context, query string, limit int) ([]DiscipleSummaryDoc, error) { + filter := bson.M{"initiatedName": bson.M{"$regex": query, "$options": "i"}} + opts := options.Find(). + SetSort(bson.M{"initiatedName": 1}). + SetLimit(int64(limit)). + SetProjection(discipleSummaryProjection) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []DiscipleSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} diff --git a/internal/repository/essay_repo.go b/internal/repository/essay_repo.go new file mode 100644 index 0000000..c698740 --- /dev/null +++ b/internal/repository/essay_repo.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type EssayRepo struct { + col *mongo.Collection +} + +func NewEssayRepo(db *MongoDB) *EssayRepo { + return &EssayRepo{col: db.Collection("essays")} +} + +type EssayDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Title string `bson:"title" json:"title"` + Author string `bson:"author" json:"author"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Group *string `bson:"group,omitempty" json:"group,omitempty"` + Order int `bson:"order" json:"order"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +type EssaySummaryDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Title string `bson:"title" json:"title"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Group *string `bson:"group,omitempty" json:"group,omitempty"` +} + +func (r *EssayRepo) GetAll(ctx context.Context) ([]EssaySummaryDoc, error) { + opts := options.Find(). + SetSort(bson.M{"group": 1, "order": 1}). + SetProjection(bson.M{"slug": 1, "title": 1, "year": 1, "group": 1}) + cursor, err := r.col.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []EssaySummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *EssayRepo) GetBySlug(ctx context.Context, slug string) (*EssayDoc, error) { + var doc EssayDoc + err := r.col.FindOne(ctx, bson.M{"slug": slug}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} diff --git a/internal/repository/glossary_repo.go b/internal/repository/glossary_repo.go new file mode 100644 index 0000000..aa2676e --- /dev/null +++ b/internal/repository/glossary_repo.go @@ -0,0 +1,220 @@ +package repository + +import ( + "context" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type GlossaryRepo struct { + col *mongo.Collection +} + +func NewGlossaryRepo(db *MongoDB) *GlossaryRepo { + return &GlossaryRepo{col: db.Collection("glossary")} +} + +// GlossaryEntryDoc is the full glossary entry with definition. +type GlossaryEntryDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Term string `bson:"term" json:"term"` + Definition string `bson:"definition" json:"definition"` + DefinitionHTML string `bson:"definitionHtml" json:"definition_html"` +} + +// GlossaryTermSummary is a lightweight entry for sidebar listing (no definition). +type GlossaryTermSummary struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Term string `bson:"term" json:"term"` +} + +// GlossaryLetterCount holds the count of entries per base letter. +type GlossaryLetterCount struct { + Letter string `json:"letter"` + Count int `json:"count"` +} + +// diacriticToBase maps diacritical first characters to ASCII base letters. +// Must match the frontend mapping in the glossary page. +var diacriticToBase = map[rune]rune{ + 'Ā': 'A', 'Ä': 'A', + 'Ī': 'I', 'É': 'I', + 'Ū': 'U', 'Ü': 'U', + 'Ṛ': 'R', 'Ṝ': 'R', + 'Ś': 'S', 'Ç': 'S', 'Ṣ': 'S', + 'Ñ': 'N', 'Ṇ': 'N', 'Ṅ': 'N', + 'Ṭ': 'T', + 'Ḍ': 'D', + 'Ḥ': 'H', + // Lowercase equivalents + 'ā': 'A', 'ä': 'A', + 'ī': 'I', 'é': 'I', + 'ū': 'U', 'ü': 'U', + 'ṛ': 'R', 'ṝ': 'R', + 'ś': 'S', 'ç': 'S', 'ṣ': 'S', + 'ñ': 'N', 'ṇ': 'N', 'ṅ': 'N', + 'ṭ': 'T', + 'ḍ': 'D', + 'ḥ': 'H', +} + +// baseLetter returns the ASCII base letter for the first character of a term. +func baseLetter(term string) string { + if term == "" { + return "?" + } + first := []rune(term)[0] + if base, ok := diacriticToBase[first]; ok { + return string(base) + } + return strings.ToUpper(string(first)) +} + +// regexPrefixes returns the set of first characters (including diacritical variants) +// that map to the given base letter, for use in a MongoDB $regex query. +func regexPrefixes(letter string) string { + upper := strings.ToUpper(letter) + if len(upper) == 0 { + return "" + } + base := rune(upper[0]) + + // Collect all characters that map to this base letter + chars := []rune{base, rune(strings.ToLower(upper)[0])} + for k, v := range diacriticToBase { + if v == base { + chars = append(chars, k) + } + } + + // Build regex character class: ^[AĀÄāä] + return "^[" + string(chars) + "]" +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +// GetAll returns all glossary entries sorted by term. +func (r *GlossaryRepo) GetAll(ctx context.Context) ([]GlossaryEntryDoc, error) { + opts := options.Find().SetSort(bson.M{"term": 1}) + cursor, err := r.col.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []GlossaryEntryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +// GetByTerm returns a single glossary entry by exact term match. +func (r *GlossaryRepo) GetByTerm(ctx context.Context, term string) (*GlossaryEntryDoc, error) { + var doc GlossaryEntryDoc + err := r.col.FindOne(ctx, bson.M{"term": term}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +// GetByLetter returns all glossary entries whose term starts with the given +// base letter (including diacritical variants). Sorted by term. +func (r *GlossaryRepo) GetByLetter(ctx context.Context, letter string) ([]GlossaryEntryDoc, error) { + regex := regexPrefixes(letter) + if regex == "" { + return nil, nil + } + + filter := bson.M{"term": bson.M{"$regex": regex}} + opts := options.Find().SetSort(bson.M{"term": 1}) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var results []GlossaryEntryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +// GetTermsByLetter returns lightweight term summaries (no definitions) for a +// given base letter. Used for sidebar listing. +func (r *GlossaryRepo) GetTermsByLetter(ctx context.Context, letter string) ([]GlossaryTermSummary, error) { + regex := regexPrefixes(letter) + if regex == "" { + return nil, nil + } + + filter := bson.M{"term": bson.M{"$regex": regex}} + opts := options.Find(). + SetSort(bson.M{"term": 1}). + SetProjection(bson.M{"term": 1}) + + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var results []GlossaryTermSummary + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +// GetLetterCounts returns the count of entries per base letter. +// This is computed in Go since MongoDB can't easily map diacritical first +// characters to base letters in an aggregation pipeline. +func (r *GlossaryRepo) GetLetterCounts(ctx context.Context) ([]GlossaryLetterCount, error) { + // Fetch only the term field + opts := options.Find().SetProjection(bson.M{"term": 1}) + cursor, err := r.col.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var docs []struct { + Term string `bson:"term"` + } + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + + counts := make(map[string]int) + for _, doc := range docs { + letter := baseLetter(doc.Term) + counts[letter]++ + } + + // Convert to sorted slice + result := make([]GlossaryLetterCount, 0, len(counts)) + for letter, count := range counts { + result = append(result, GlossaryLetterCount{Letter: letter, Count: count}) + } + + // Sort alphabetically + for i := 0; i < len(result); i++ { + for j := i + 1; j < len(result); j++ { + if result[j].Letter < result[i].Letter { + result[i], result[j] = result[j], result[i] + } + } + } + + return result, nil +} diff --git a/internal/repository/journal_repo.go b/internal/repository/journal_repo.go new file mode 100644 index 0000000..2b8ca7b --- /dev/null +++ b/internal/repository/journal_repo.go @@ -0,0 +1,89 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type JournalRepo struct { + meta *mongo.Collection + chapters *mongo.Collection +} + +func NewJournalRepo(db *MongoDB) *JournalRepo { + return &JournalRepo{ + meta: db.Collection("journal_meta"), + chapters: db.Collection("journal_chapters"), + } +} + +type JournalMetaDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Data interface{} `bson:"data" json:"data"` +} + +type JournalChapterDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Chapter int `bson:"chapter" json:"chapter"` + Title string `bson:"title" json:"title"` + Type string `bson:"type" json:"type"` + Slug string `bson:"slug" json:"slug"` + Order int `bson:"order" json:"order"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +func (r *JournalRepo) GetAllMetas(ctx context.Context) ([]JournalMetaDoc, error) { + cursor, err := r.meta.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []JournalMetaDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *JournalRepo) GetMeta(ctx context.Context, book string) (*JournalMetaDoc, error) { + var doc JournalMetaDoc + err := r.meta.FindOne(ctx, bson.M{"book": book}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +func (r *JournalRepo) GetChapters(ctx context.Context, book string) ([]JournalChapterDoc, error) { + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.chapters.Find(ctx, bson.M{"book": book}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []JournalChapterDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *JournalRepo) GetChapter(ctx context.Context, book, slug string) (*JournalChapterDoc, error) { + var doc JournalChapterDoc + err := r.chapters.FindOne(ctx, bson.M{"book": book, "slug": slug}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} diff --git a/internal/repository/letter_repo.go b/internal/repository/letter_repo.go new file mode 100644 index 0000000..88bc501 --- /dev/null +++ b/internal/repository/letter_repo.go @@ -0,0 +1,166 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type LetterRepo struct { + col *mongo.Collection +} + +func NewLetterRepo(db *MongoDB) *LetterRepo { + return &LetterRepo{col: db.Collection("letters")} +} + +type LetterDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Recipients []string `bson:"recipients" json:"recipients"` + RecipientDisplay string `bson:"recipientDisplay" json:"recipient_display"` + Date *string `bson:"date,omitempty" json:"date,omitempty"` + DateRef *string `bson:"dateRef,omitempty" json:"date_ref,omitempty"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Month *int `bson:"month,omitempty" json:"month,omitempty"` + Day *int `bson:"day,omitempty" json:"day,omitempty"` + Location *string `bson:"location,omitempty" json:"location,omitempty"` + Salutation *string `bson:"salutation,omitempty" json:"salutation,omitempty"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +type LetterSummaryDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Recipients []string `bson:"recipients" json:"recipients"` + RecipientDisplay string `bson:"recipientDisplay" json:"recipient_display"` + Date *string `bson:"date,omitempty" json:"date,omitempty"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Month *int `bson:"month,omitempty" json:"month,omitempty"` + Location *string `bson:"location,omitempty" json:"location,omitempty"` +} + +var letterSummaryProjection = bson.M{ + "slug": 1, "recipients": 1, "recipientDisplay": 1, + "date": 1, "year": 1, "month": 1, "location": 1, +} + +func (r *LetterRepo) GetBySlug(ctx context.Context, slug string) (*LetterDoc, error) { + var doc LetterDoc + err := r.col.FindOne(ctx, bson.M{"slug": slug}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +func (r *LetterRepo) GetByYear(ctx context.Context, year int) ([]LetterSummaryDoc, error) { + opts := options.Find(). + SetSort(bson.M{"month": 1, "day": 1}). + SetProjection(letterSummaryProjection) + cursor, err := r.col.Find(ctx, bson.M{"year": year}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []LetterSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) GetByRecipient(ctx context.Context, name string) ([]LetterSummaryDoc, error) { + opts := options.Find(). + SetSort(bson.M{"year": 1, "month": 1, "day": 1}). + SetProjection(letterSummaryProjection) + cursor, err := r.col.Find(ctx, bson.M{"recipients": name}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []LetterSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) GetByLocation(ctx context.Context, location string) ([]LetterSummaryDoc, error) { + opts := options.Find(). + SetSort(bson.M{"year": 1, "month": 1, "day": 1}). + SetProjection(letterSummaryProjection) + cursor, err := r.col.Find(ctx, bson.M{"location": location}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []LetterSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) GetYears(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$match": bson.M{"year": bson.M{"$ne": nil}}}, + bson.M{"$group": bson.M{"_id": "$year", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"_id": 1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) GetRecipients(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$unwind": "$recipients"}, + bson.M{"$group": bson.M{"_id": "$recipients", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"count": -1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) GetLocations(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$match": bson.M{"location": bson.M{"$ne": nil}}}, + bson.M{"$group": bson.M{"_id": "$location", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"count": -1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *LetterRepo) Count(ctx context.Context) (int64, error) { + return r.col.CountDocuments(ctx, bson.M{}) +} diff --git a/internal/repository/magazine_repo.go b/internal/repository/magazine_repo.go new file mode 100644 index 0000000..4d7cd59 --- /dev/null +++ b/internal/repository/magazine_repo.go @@ -0,0 +1,89 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type MagazineRepo struct { + meta *mongo.Collection + issues *mongo.Collection +} + +func NewMagazineRepo(db *MongoDB) *MagazineRepo { + return &MagazineRepo{ + meta: db.Collection("magazine_meta"), + issues: db.Collection("magazine_issues"), + } +} + +type MagazineMetaDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Data interface{} `bson:"data" json:"data"` +} + +type MagazineIssueDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Volume *int `bson:"volume,omitempty" json:"volume,omitempty"` + Part int `bson:"part" json:"part"` + Title string `bson:"title" json:"title"` + Slug string `bson:"slug" json:"slug"` + Order int `bson:"order" json:"order"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +func (r *MagazineRepo) GetAllMetas(ctx context.Context) ([]MagazineMetaDoc, error) { + cursor, err := r.meta.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []MagazineMetaDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *MagazineRepo) GetMeta(ctx context.Context, book string) (*MagazineMetaDoc, error) { + var doc MagazineMetaDoc + err := r.meta.FindOne(ctx, bson.M{"book": book}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +func (r *MagazineRepo) GetIssues(ctx context.Context, book string) ([]MagazineIssueDoc, error) { + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.issues.Find(ctx, bson.M{"book": book}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []MagazineIssueDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *MagazineRepo) GetIssue(ctx context.Context, book, slug string) (*MagazineIssueDoc, error) { + var doc MagazineIssueDoc + err := r.issues.FindOne(ctx, bson.M{"book": book, "slug": slug}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} diff --git a/internal/repository/mongo.go b/internal/repository/mongo.go new file mode 100644 index 0000000..52a6f82 --- /dev/null +++ b/internal/repository/mongo.go @@ -0,0 +1,54 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.uber.org/zap" +) + +// MongoDB holds the MongoDB client and database reference. +type MongoDB struct { + Client *mongo.Client + DB *mongo.Database + logger *zap.Logger +} + +// NewMongoDB connects to MongoDB and returns a MongoDB instance. +func NewMongoDB(uri, dbName string, logger *zap.Logger) (*MongoDB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientOpts := options.Client().ApplyURI(uri) + client, err := mongo.Connect(clientOpts) + if err != nil { + return nil, fmt.Errorf("mongo connect: %w", err) + } + + if err := client.Ping(ctx, nil); err != nil { + return nil, fmt.Errorf("mongo ping: %w", err) + } + + logger.Info("connected to MongoDB", zap.String("db", dbName)) + + return &MongoDB{ + Client: client, + DB: client.Database(dbName), + logger: logger, + }, nil +} + +// Close disconnects from MongoDB. +func (m *MongoDB) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return m.Client.Disconnect(ctx) +} + +// Collection returns a MongoDB collection handle. +func (m *MongoDB) Collection(name string) *mongo.Collection { + return m.DB.Collection(name) +} diff --git a/internal/repository/other_document_repo.go b/internal/repository/other_document_repo.go new file mode 100644 index 0000000..f10ce91 --- /dev/null +++ b/internal/repository/other_document_repo.go @@ -0,0 +1,89 @@ +package repository + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type OtherDocumentRepo struct { + meta *mongo.Collection + chapters *mongo.Collection +} + +func NewOtherDocumentRepo(db *MongoDB) *OtherDocumentRepo { + return &OtherDocumentRepo{ + meta: db.Collection("other_document_meta"), + chapters: db.Collection("other_document_chapters"), + } +} + +type OtherDocumentMetaDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Data interface{} `bson:"data" json:"data"` +} + +type OtherDocumentChapterDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Book string `bson:"book" json:"book"` + Chapter int `bson:"chapter" json:"chapter"` + Title string `bson:"title" json:"title"` + Type string `bson:"type" json:"type"` + Slug string `bson:"slug" json:"slug"` + Order int `bson:"order" json:"order"` + Paragraphs []string `bson:"paragraphs" json:"paragraphs"` +} + +func (r *OtherDocumentRepo) GetAllMetas(ctx context.Context) ([]OtherDocumentMetaDoc, error) { + cursor, err := r.meta.Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []OtherDocumentMetaDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *OtherDocumentRepo) GetMeta(ctx context.Context, book string) (*OtherDocumentMetaDoc, error) { + var doc OtherDocumentMetaDoc + err := r.meta.FindOne(ctx, bson.M{"book": book}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} + +func (r *OtherDocumentRepo) GetChapters(ctx context.Context, book string) ([]OtherDocumentChapterDoc, error) { + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.chapters.Find(ctx, bson.M{"book": book}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []OtherDocumentChapterDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *OtherDocumentRepo) GetChapter(ctx context.Context, book, slug string) (*OtherDocumentChapterDoc, error) { + var doc OtherDocumentChapterDoc + err := r.chapters.FindOne(ctx, bson.M{"book": book, "slug": slug}).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &doc, nil +} diff --git a/internal/repository/transcription_repo.go b/internal/repository/transcription_repo.go new file mode 100644 index 0000000..1ab0455 --- /dev/null +++ b/internal/repository/transcription_repo.go @@ -0,0 +1,233 @@ +package repository + +import ( + "context" + "encoding/json" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type TranscriptionRepo struct { + col *mongo.Collection +} + +func NewTranscriptionRepo(db *MongoDB) *TranscriptionRepo { + return &TranscriptionRepo{col: db.Collection("transcriptions")} +} + +type ContentBlockDoc struct { + Text string `bson:"text" json:"text"` + Format string `bson:"format" json:"format"` + Alignment string `bson:"alignment" json:"alignment"` + Speaker *string `bson:"speaker,omitempty" json:"speaker,omitempty"` +} + +type TranscriptionDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Name string `bson:"name" json:"name"` + Type string `bson:"type" json:"type"` + Date *string `bson:"date,omitempty" json:"date,omitempty"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Month *int `bson:"month,omitempty" json:"month,omitempty"` + Day *int `bson:"day,omitempty" json:"day,omitempty"` + Place *string `bson:"place,omitempty" json:"place,omitempty"` + Context *string `bson:"context,omitempty" json:"context,omitempty"` + Reference *string `bson:"reference,omitempty" json:"reference,omitempty"` + DurationMinutes *int `bson:"durationMinutes,omitempty" json:"duration_minutes,omitempty"` + AudioUrl *string `bson:"audioUrl,omitempty" json:"audio_url,omitempty"` + Participants []string `bson:"participants" json:"participants"` + Content []ContentBlockDoc `bson:"content" json:"content"` + ContentJson *string `bson:"contentJson,omitempty" json:"-"` + Tags []string `bson:"tags" json:"tags"` + Language string `bson:"language" json:"language"` + Source *string `bson:"source,omitempty" json:"source,omitempty"` + IsPublished bool `bson:"isPublished" json:"is_published"` +} + +type TranscriptionSummaryDoc struct { + ID bson.ObjectID `bson:"_id" json:"id"` + Slug string `bson:"slug" json:"slug"` + Name string `bson:"name" json:"name"` + Type string `bson:"type" json:"type"` + Date *string `bson:"date,omitempty" json:"date,omitempty"` + Year *int `bson:"year,omitempty" json:"year,omitempty"` + Month *int `bson:"month,omitempty" json:"month,omitempty"` + Day *int `bson:"day,omitempty" json:"day,omitempty"` + Place *string `bson:"place,omitempty" json:"place,omitempty"` + Context *string `bson:"context,omitempty" json:"context,omitempty"` + Reference *string `bson:"reference,omitempty" json:"reference,omitempty"` + DurationMinutes *int `bson:"durationMinutes,omitempty" json:"duration_minutes,omitempty"` + AudioUrl *string `bson:"audioUrl,omitempty" json:"audio_url,omitempty"` + Participants []string `bson:"participants" json:"participants"` +} + +var publishedFilter = bson.M{"isPublished": true} + +var transcriptionSummaryProjection = bson.M{ + "slug": 1, "name": 1, "type": 1, "date": 1, + "year": 1, "month": 1, "day": 1, "place": 1, + "context": 1, "reference": 1, "durationMinutes": 1, + "audioUrl": 1, "participants": 1, +} + +func (r *TranscriptionRepo) GetBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) { + filter := bson.M{"slug": slug, "isPublished": true} + var doc TranscriptionDoc + err := r.col.FindOne(ctx, filter).Decode(&doc) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + if len(doc.Content) == 0 && doc.ContentJson != nil && *doc.ContentJson != "" { + var blocks []ContentBlockDoc + if err := json.Unmarshal([]byte(*doc.ContentJson), &blocks); err == nil { + doc.Content = blocks + } + } + doc.ContentJson = nil + return &doc, nil +} + +func (r *TranscriptionRepo) GetByYear(ctx context.Context, year int) ([]TranscriptionSummaryDoc, error) { + filter := bson.M{"year": year, "isPublished": true} + opts := options.Find(). + SetSort(bson.M{"month": 1, "day": 1}). + SetProjection(transcriptionSummaryProjection) + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []TranscriptionSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *TranscriptionRepo) GetByPlace(ctx context.Context, place string) ([]TranscriptionSummaryDoc, error) { + filter := bson.M{"place": place, "isPublished": true} + opts := options.Find(). + SetSort(bson.M{"year": 1, "month": 1, "day": 1}). + SetProjection(transcriptionSummaryProjection) + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []TranscriptionSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *TranscriptionRepo) GetByType(ctx context.Context, docType string) ([]TranscriptionSummaryDoc, error) { + filter := bson.M{"type": docType, "isPublished": true} + opts := options.Find(). + SetSort(bson.M{"year": 1, "month": 1, "day": 1}). + SetProjection(transcriptionSummaryProjection) + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []TranscriptionSummaryDoc + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *TranscriptionRepo) GetYears(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$match": bson.M{"isPublished": true, "year": bson.M{"$ne": nil}}}, + bson.M{"$group": bson.M{"_id": "$year", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"_id": 1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *TranscriptionRepo) GetPlaces(ctx context.Context) ([]bson.M, error) { + pipeline := bson.A{ + bson.M{"$match": bson.M{"isPublished": true, "place": bson.M{"$ne": nil}}}, + bson.M{"$group": bson.M{"_id": "$place", "count": bson.M{"$sum": 1}}}, + bson.M{"$sort": bson.M{"count": -1}}, + } + cursor, err := r.col.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var results []bson.M + if err := cursor.All(ctx, &results); err != nil { + return nil, err + } + return results, nil +} + +func (r *TranscriptionRepo) GetTypeDistribution(ctx context.Context) (int64, int64, error) { + lectureCount, err := r.col.CountDocuments(ctx, bson.M{"type": "lecture", "isPublished": true}) + if err != nil { + return 0, 0, err + } + conversationCount, err := r.col.CountDocuments(ctx, bson.M{"type": "conversation", "isPublished": true}) + if err != nil { + return 0, 0, err + } + return lectureCount, conversationCount, nil +} + +func (r *TranscriptionRepo) Count(ctx context.Context) (int64, error) { + return r.col.CountDocuments(ctx, publishedFilter) +} + +func (r *TranscriptionRepo) GetAdjacent(ctx context.Context, slug string, year int) (*TranscriptionSummaryDoc, *TranscriptionSummaryDoc, error) { + filter := bson.M{"year": year, "isPublished": true} + opts := options.Find(). + SetSort(bson.M{"month": 1, "day": 1}). + SetProjection(transcriptionSummaryProjection) + cursor, err := r.col.Find(ctx, filter, opts) + if err != nil { + return nil, nil, err + } + defer cursor.Close(ctx) + var summaries []TranscriptionSummaryDoc + if err := cursor.All(ctx, &summaries); err != nil { + return nil, nil, err + } + + idx := -1 + for i, s := range summaries { + if s.Slug == slug { + idx = i + break + } + } + if idx < 0 { + return nil, nil, nil + } + + var prev, next *TranscriptionSummaryDoc + if idx > 0 { + prev = &summaries[idx-1] + } + if idx < len(summaries)-1 { + next = &summaries[idx+1] + } + return prev, next, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..28af8b3 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,331 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + chiMiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/prabhupadasays/spsays-bff/internal/config" + "github.com/prabhupadasays/spsays-bff/internal/handler" + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// Server holds all dependencies for the HTTP server. +type Server struct { + cfg *config.Config + logger *zap.Logger + router chi.Router + mongo *repository.MongoDB + + // Handlers + bookH *handler.BookHandler + letterH *handler.LetterHandler + essayH *handler.EssayHandler + glossaryH *handler.GlossaryHandler + discipleH *handler.DiscipleHandler + journalH *handler.JournalHandler + magazineH *handler.MagazineHandler + otherDocH *handler.OtherDocumentHandler + transcriptionH *handler.TranscriptionHandler +} + +// New creates a new Server with all routes registered. +func New(cfg *config.Config, logger *zap.Logger) *Server { + mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger) + if err != nil { + logger.Fatal("failed to connect to MongoDB", zap.Error(err)) + } + + // Initialize repositories + bookRepo := repository.NewBookRepo(mongoDB) + letterRepo := repository.NewLetterRepo(mongoDB) + essayRepo := repository.NewEssayRepo(mongoDB) + glossaryRepo := repository.NewGlossaryRepo(mongoDB) + discipleRepo := repository.NewDiscipleRepo(mongoDB) + journalRepo := repository.NewJournalRepo(mongoDB) + magazineRepo := repository.NewMagazineRepo(mongoDB) + otherDocRepo := repository.NewOtherDocumentRepo(mongoDB) + transcriptionRepo := repository.NewTranscriptionRepo(mongoDB) + + s := &Server{ + cfg: cfg, + logger: logger, + mongo: mongoDB, + + // Initialize handlers + bookH: handler.NewBookHandler(bookRepo), + letterH: handler.NewLetterHandler(letterRepo), + essayH: handler.NewEssayHandler(essayRepo), + glossaryH: handler.NewGlossaryHandler(glossaryRepo), + discipleH: handler.NewDiscipleHandler(discipleRepo), + journalH: handler.NewJournalHandler(journalRepo), + magazineH: handler.NewMagazineHandler(magazineRepo), + otherDocH: handler.NewOtherDocumentHandler(otherDocRepo), + transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo), + } + + s.setupRouter() + return s +} + +// Router returns the chi router. +func (s *Server) Router() http.Handler { + return s.router +} + +// Close shuts down all connections gracefully. +func (s *Server) Close() { + if err := s.mongo.Close(); err != nil { + s.logger.Error("failed to close MongoDB connection", zap.Error(err)) + } + // TODO: close PostgreSQL, Redis, etc. connections + s.logger.Info("all connections closed") +} + +// injectBookCode is a middleware that sets "code" in chi's URL params +// for explicit sub-routes like /books/sb and /books/cc where {code} +// isn't a URL param but a fixed path prefix. +func injectBookCode(code string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rctx := chi.RouteContext(r.Context()) + rctx.URLParams.Add("code", code) + next.ServeHTTP(w, r) + }) + } +} + +func (s *Server) setupRouter() { + r := chi.NewRouter() + + // Global middleware + r.Use(chiMiddleware.RequestID) + r.Use(chiMiddleware.RealIP) + r.Use(middleware.Logger(s.logger)) + r.Use(chiMiddleware.Recoverer) + r.Use(chiMiddleware.Compress(5)) + + // CORS + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{s.cfg.FrontendURL}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"}, + ExposedHeaders: []string{"X-Request-ID"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Health check + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + // API v1 + r.Route("/api/v1", func(r chi.Router) { + // Books + r.Route("/books", func(r chi.Router) { + r.Get("/", s.bookH.ListBooks) + + // --- Verse books with divisions --- + + // SB — canto divisions + r.Route("/sb", func(r chi.Router) { + r.Use(injectBookCode("sb")) + r.Get("/", s.bookH.GetBook) + r.Get("/{division}", s.bookH.GetDivisionInfo) + r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex) + r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses) + r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse) + }) + + // CC — līlā divisions + r.Route("/cc", func(r chi.Router) { + r.Use(injectBookCode("cc")) + r.Get("/", s.bookH.GetBook) + r.Get("/{division}", s.bookH.GetDivisionInfo) + r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex) + r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses) + r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse) + }) + + // --- Verse books without divisions --- + // BG, SB1962: pure verse books + for _, code := range []string{"bg", "sb1962"} { + code := code // capture for closure + r.Route("/"+code, func(r chi.Router) { + r.Use(injectBookCode(code)) + r.Get("/", s.bookH.GetBook) + r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) + r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) + r.Get("/{chapter}/{verse}", s.bookH.GetVerse) + }) + } + + // --- Verse + prose intro books (no division) --- + // BS, MM, NBS: have both verses and prose intro segments + for _, code := range []string{"bs", "mm", "nbs"} { + code := code + r.Route("/"+code, func(r chi.Router) { + r.Use(injectBookCode(code)) + r.Get("/", s.bookH.GetBook) + r.Get("/chapters", s.bookH.ListProseChapters) + r.Get("/chapters/{slug}", s.bookH.GetProseChapter) + r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) + r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) + r.Get("/{chapter}/{verse}", s.bookH.GetVerse) + }) + } + + // --- Verse + prose intro books with special slugs --- + // ISO, NOI: mantras/texts accessed by slug, plus prose intros + for _, code := range []string{"iso", "noi"} { + code := code + r.Route("/"+code, func(r chi.Router) { + r.Use(injectBookCode(code)) + r.Get("/", s.bookH.GetBook) + r.Get("/chapters", s.bookH.ListProseChapters) + r.Get("/chapters/{slug}", s.bookH.GetProseChapter) + r.Get("/{chapter}", s.bookH.GetChapterVerseIndex) + r.Get("/{chapter}/verses", s.bookH.GetChapterVerses) + r.Get("/{chapter}/{verse}", s.bookH.GetVerse) + }) + } + + // --- Volume-based prose book --- + // KB: 2 volumes, chapters within each volume + r.Route("/kb", func(r chi.Router) { + r.Use(injectBookCode("kb")) + r.Get("/", s.bookH.GetBook) + r.Get("/{volume}", s.bookH.ListVolumeChapters) + r.Get("/{volume}/{slug}", s.bookH.GetVolumeChapter) + }) + + // --- Simple prose books --- + // BBD, BK, CBHC, DS, EJ, EK, GG, HKH, LCL, LOB, MG, MOG, NOD, + // OCHKM, OWK, PK, POY, PQPA, RV, SSR, SVA, TLC, TLK, TQK, TTP, VA + for _, code := range []string{ + "bbd", "bk", "cbhc", "ds", "ej", "ek", "gg", "hkh", + "lcl", "lob", "mg", "mog", "nod", "ochkm", "owk", "pk", + "poy", "pqpa", "rv", "ssr", "sva", "tlc", "tlk", "tqk", + "ttp", "va", + } { + code := code + r.Route("/"+code, func(r chi.Router) { + r.Use(injectBookCode(code)) + r.Get("/", s.bookH.GetBook) + r.Get("/{slug}", s.bookH.GetProseChapter) + r.Get("/chapters", s.bookH.ListProseChapters) + r.Get("/chapters/{slug}", s.bookH.GetProseChapter) + }) + } + }) + + // Letters + r.Route("/letters", func(r chi.Router) { + r.Get("/", s.letterH.Count) + r.Get("/years", s.letterH.GetYears) + r.Get("/recipients", s.letterH.GetRecipients) + r.Get("/locations", s.letterH.GetLocations) + r.Get("/year/{year}", s.letterH.GetByYear) + r.Get("/recipient/{name}", s.letterH.GetByRecipient) + r.Get("/location/{name}", s.letterH.GetByLocation) + r.Get("/{slug}", s.letterH.GetBySlug) + }) + + // Essays + r.Route("/essays", func(r chi.Router) { + r.Get("/", s.essayH.List) + r.Get("/{slug}", s.essayH.GetBySlug) + }) + + // Glossary + r.Route("/glossary", func(r chi.Router) { + r.Get("/", s.glossaryH.List) + r.Get("/letters", s.glossaryH.GetLetterCounts) + r.Get("/letter/{letter}", s.glossaryH.GetByLetter) + r.Get("/letter/{letter}/terms", s.glossaryH.GetTermsByLetter) + r.Get("/term/{term}", s.glossaryH.GetByTerm) + }) + + // Disciples + r.Route("/disciples", func(r chi.Router) { + r.Get("/", s.discipleH.List) + r.Get("/count", s.discipleH.Count) + r.Get("/letters", s.discipleH.LetterCounts) + r.Get("/suffixes", s.discipleH.SuffixCounts) + r.Get("/search", s.discipleH.Search) + }) + + // Journals + r.Route("/journals", func(r chi.Router) { + r.Get("/", s.journalH.ListJournals) + r.Get("/{book}", s.journalH.GetJournal) + r.Get("/{book}/chapters", s.journalH.ListChapters) + r.Get("/{book}/chapters/{slug}", s.journalH.GetChapter) + }) + + // Magazines + r.Route("/magazines", func(r chi.Router) { + r.Get("/", s.magazineH.ListMagazines) + r.Get("/{book}", s.magazineH.GetMagazine) + r.Get("/{book}/issues", s.magazineH.ListIssues) + r.Get("/{book}/issues/{slug}", s.magazineH.GetIssue) + }) + + // Other Documents + r.Route("/other-documents", func(r chi.Router) { + r.Get("/", s.otherDocH.ListDocuments) + r.Get("/{book}", s.otherDocH.GetDocument) + r.Get("/{book}/chapters", s.otherDocH.ListChapters) + r.Get("/{book}/chapters/{slug}", s.otherDocH.GetChapter) + }) + + // Transcriptions + r.Route("/transcriptions", func(r chi.Router) { + r.Get("/", s.transcriptionH.Count) + r.Get("/years", s.transcriptionH.GetYears) + r.Get("/places", s.transcriptionH.GetPlaces) + r.Get("/types", s.transcriptionH.GetTypeDistribution) + r.Get("/year/{year}", s.transcriptionH.GetByYear) + r.Get("/place/{place}", s.transcriptionH.GetByPlace) + r.Get("/type/{type}", s.transcriptionH.GetByType) + r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent) + r.Get("/{slug}", s.transcriptionH.GetBySlug) + }) + + // Search (Meilisearch) + r.Route("/search", func(r chi.Router) { + // TODO: register search handlers + }) + + // User routes (PostgreSQL) + r.Route("/auth", func(r chi.Router) { + // TODO: register auth handlers (login, register, refresh) + }) + r.Route("/users", func(r chi.Router) { + // TODO: register user handlers + }) + r.Route("/collections", func(r chi.Router) { + // TODO: register collection/bookmark handlers + }) + r.Route("/mentions", func(r chi.Router) { + // TODO: register mention handlers + }) + + // URL Shortener + r.Route("/s", func(r chi.Router) { + // TODO: register URL shortener handlers + }) + + // Push Notifications + r.Route("/notifications", func(r chi.Router) { + // TODO: register push notification handlers + }) + }) + + s.router = r +} diff --git a/internal/service/.gitkeep b/internal/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/push/.gitkeep b/pkg/push/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..a304d86 --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,67 @@ +package response + +import ( + "encoding/json" + "net/http" +) + +// Envelope wraps API responses in a consistent structure. +type Envelope struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorBody `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrorBody represents an error response. +type ErrorBody struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Meta holds pagination and other metadata. +type Meta struct { + Page int `json:"page,omitempty"` + Limit int `json:"limit,omitempty"` + Total int64 `json:"total,omitempty"` + TotalPages int `json:"total_pages,omitempty"` +} + +// JSON sends a success response with the given data. +func JSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(Envelope{ + Success: true, + Data: data, + }) +} + +// JSONWithMeta sends a success response with data and pagination metadata. +func JSONWithMeta(w http.ResponseWriter, status int, data interface{}, meta *Meta) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(Envelope{ + Success: true, + Data: data, + Meta: meta, + }) +} + +// Error sends an error response. +func Error(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(Envelope{ + Success: false, + Error: &ErrorBody{ + Code: status, + Message: message, + }, + }) +} + +// NoContent sends a 204 No Content response. +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/shorturl/.gitkeep b/pkg/shorturl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go new file mode 100644 index 0000000..5034807 --- /dev/null +++ b/pkg/validator/validator.go @@ -0,0 +1,77 @@ +package validator + +import ( + "fmt" + "net/mail" + "strings" + "unicode/utf8" +) + +// Errors collects validation errors for a request. +type Errors struct { + Fields map[string]string `json:"fields"` +} + +// New creates a new Errors instance. +func New() *Errors { + return &Errors{Fields: make(map[string]string)} +} + +// Add records a validation error for a field. +func (e *Errors) Add(field, message string) { + e.Fields[field] = message +} + +// Valid returns true if there are no validation errors. +func (e *Errors) Valid() bool { + return len(e.Fields) == 0 +} + +// Error implements the error interface. +func (e *Errors) Error() string { + parts := make([]string, 0, len(e.Fields)) + for field, msg := range e.Fields { + parts = append(parts, fmt.Sprintf("%s: %s", field, msg)) + } + return strings.Join(parts, "; ") +} + +// --- Common validators --- + +// Required checks that a string is not empty. +func Required(v *Errors, field, value string) { + if strings.TrimSpace(value) == "" { + v.Add(field, "is required") + } +} + +// MaxLength checks that a string doesn't exceed a maximum length. +func MaxLength(v *Errors, field, value string, max int) { + if utf8.RuneCountInString(value) > max { + v.Add(field, fmt.Sprintf("must be at most %d characters", max)) + } +} + +// MinLength checks that a string meets a minimum length. +func MinLength(v *Errors, field, value string, min int) { + if utf8.RuneCountInString(value) < min { + v.Add(field, fmt.Sprintf("must be at least %d characters", min)) + } +} + +// Email checks that a string is a valid email address. +func Email(v *Errors, field, value string) { + if _, err := mail.ParseAddress(value); err != nil { + v.Add(field, "must be a valid email address") + } +} + +// In checks that a string is one of the allowed values. +func In(v *Errors, field, value string, allowed ...string) { + for _, a := range allowed { + if value == a { + return + } + } + v.Add(field, fmt.Sprintf("must be one of: %s", strings.Join(allowed, ", "))) +}