feat: add books APIs

This commit is contained in:
gosangam
2026-05-09 23:19:19 +05:30
commit b055b8ba0c
43 changed files with 4307 additions and 0 deletions
+38
View File
@@ -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
+33
View File
@@ -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/
View File
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+35
View File
@@ -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
+417
View File
@@ -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
+73
View File
@@ -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")
}
+24
View File
@@ -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
)
+66
View File
@@ -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=
+112
View File
@@ -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
}
+633
View File
@@ -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
}
+93
View File
@@ -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)
}
+40
View File
@@ -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)
}
+97
View File
@@ -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)
}
+22
View File
@@ -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",
})
}
+65
View File
@@ -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)
}
+103
View File
@@ -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})
}
+65
View File
@@ -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)
}
+65
View File
@@ -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)
}
+129
View File
@@ -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,
})
}
+57
View File
@@ -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 ""
}
+32
View File
@@ -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())),
)
})
}
}
+81
View File
@@ -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()
}
}
+116
View File
@@ -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"`
}
+60
View File
@@ -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"`
}
View File
+211
View File
@@ -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
}
+160
View File
@@ -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
}
+64
View File
@@ -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
}
+220
View File
@@ -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
}
+89
View File
@@ -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
}
+166
View File
@@ -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{})
}
+89
View File
@@ -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
}
+54
View File
@@ -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)
}
@@ -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
}
+233
View File
@@ -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
}
+331
View File
@@ -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
}
View File
View File
View File
+67
View File
@@ -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)
}
View File
+77
View File
@@ -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, ", ")))
}