feat: add books APIs
This commit is contained in:
@@ -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
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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())),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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, ", ")))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user