feat: add books APIs
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user