package handlers import ( "context" "fmt" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type CategoryDTO struct { ID int `json:"id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description,omitempty"` IconKey string `json:"iconKey"` SortOrder int `json:"sortOrder"` UpdatedAt string `json:"updatedAt,omitempty"` } type ResourceDTO struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description,omitempty"` Type string `json:"type"` Language string `json:"language"` CategoryID int `json:"categoryId"` CategorySlug string `json:"categorySlug"` CategoryName string `json:"categoryName"` CoverImage string `json:"coverImage,omitempty"` FileURL string `json:"fileUrl,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` ExternalURL string `json:"externalUrl,omitempty"` BodyText string `json:"bodyText,omitempty"` BadgeLabel string `json:"badgeLabel,omitempty"` IsDownloadable bool `json:"isDownloadable"` IsRecommended bool `json:"isRecommended"` PublishedAt *string `json:"publishedAt,omitempty"` UpdatedAt string `json:"updatedAt"` Tags []string `json:"tags,omitempty"` } func ListCategories(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) rows, err := pool.Query(r.Context(), ` SELECT c.id, c.slug, `+categoryI18nColsSQL+`, c.icon_key, c.sort_order, c.updated_at FROM categories c WHERE c.is_visible = TRUE ORDER BY c.sort_order ASC, c.id ASC`) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() out := make([]CategoryDTO, 0) for rows.Next() { var c CategoryDTO var nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs string var descZh, descEn, descJa, descKo, descVi, descId, descMs string var updated time.Time if err := rows.Scan(&c.ID, &c.Slug, &nameZh, &nameEn, &nameJa, &nameKo, &nameVi, &nameId, &nameMs, &descZh, &descEn, &descJa, &descKo, &descVi, &descId, &descMs, &c.IconKey, &c.SortOrder, &updated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } texts := scanCategoryTextI18n(nameZh, nameEn, nameJa, nameKo, nameVi, nameId, nameMs, descZh, descEn, descJa, descKo, descVi, descId, descMs) c.Name = texts.pickName(r) c.Description = texts.pickDesc(r) c.UpdatedAt = updated.UTC().Format(time.RFC3339) out = append(out, c) } if err := rows.Err(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, out) } func deref(s *string) string { if s == nil { return "" } return *s } func ListResources(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) page := atoiDef(r.URL.Query().Get("page"), 1) limit := atoiDef(r.URL.Query().Get("limit"), 20) if limit > 100 { limit = 100 } offset := (page - 1) * limit q := strings.TrimSpace(r.URL.Query().Get("q")) typ := strings.TrimSpace(r.URL.Query().Get("type")) catSlug := strings.TrimSpace(r.URL.Query().Get("category")) sort := strings.TrimSpace(r.URL.Query().Get("sort")) if sort == "" { sort = "latest" } order := "r.updated_at DESC" switch sort { case "published": order = "r.published_at DESC NULLS LAST, r.updated_at DESC" case "recommended": order = "r.is_recommended DESC, r.sort_order DESC, r.updated_at DESC" case "popular": order = "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC" } base := ` SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE` args := []any{} cond := "" if catSlug != "" { args = append(args, catSlug) cond += " AND c.slug = $" + strconv.Itoa(len(args)) } if typ != "" && typ != "all" { args = append(args, typ) cond += " AND r.type = $" + strconv.Itoa(len(args)) } if q != "" { pat := "%" + q + "%" start := len(args) + 1 args = append(args, pat) cond += fmt.Sprintf(` AND ( r.title_zh ILIKE $%d OR r.title_en ILIKE $%d OR r.title_ja ILIKE $%d OR r.title_ko ILIKE $%d OR r.title_vi ILIKE $%d OR r.title_id ILIKE $%d OR r.title_ms ILIKE $%d OR r.description_zh ILIKE $%d OR r.description_en ILIKE $%d OR r.description_ja ILIKE $%d OR r.description_ko ILIKE $%d OR r.description_vi ILIKE $%d OR r.description_id ILIKE $%d OR r.description_ms ILIKE $%d OR EXISTS (SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND t.name ILIKE $%d))`, start, start, start, start, start, start, start, start, start, start, start, start, start, start, start) } tag := strings.TrimSpace(r.URL.Query().Get("tag")) if tag != "" { tagLower := strings.ToLower(tag) args = append(args, tagLower) i := strconv.Itoa(len(args)) cond += ` AND EXISTS ( SELECT 1 FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = r.id AND (lower(t.slug) = $` + i + ` OR lower(t.name) = $` + i + `))` } countSQL := `SELECT COUNT(*) FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE` + cond var total int if err := pool.QueryRow(r.Context(), countSQL, args...).Scan(&total); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sqlStr := base + cond + " ORDER BY " + order + " LIMIT $" + strconv.Itoa(len(args)+1) + " OFFSET $" + strconv.Itoa(len(args)+2) args = append(args, limit, offset) rows, err := pool.Query(r.Context(), sqlStr, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() items, err := readResourceRows(rows, r, pool) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"items": items, "page": page, "limit": limit, "total": total}) } func ListRecommended(w http.ResponseWriter, r *http.Request) { listFeatured(w, r, "r.is_recommended = TRUE", "r.sort_order DESC, r.updated_at DESC") } func ListLatest(w http.ResponseWriter, r *http.Request) { listFeatured(w, r, "TRUE", "r.updated_at DESC") } func ListPopular(w http.ResponseWriter, r *http.Request) { listFeatured(w, r, "TRUE", "(r.download_count + r.favorite_count + r.share_count) DESC, r.updated_at DESC") } func listFeatured(w http.ResponseWriter, r *http.Request, extra string, order string) { pool := poolFrom(r) limit := atoiDef(r.URL.Query().Get("limit"), 12) if limit > 50 { limit = 50 } sqlStr := ` SELECT r.id, ` + resourceI18nColsSQL + `, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, ` + categoryNameColsSQL + `, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE AND (` + extra + `) ORDER BY ` + order + ` LIMIT $1` args := []any{limit} rows, err := pool.Query(r.Context(), sqlStr, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() items, err := readResourceRows(rows, r, pool) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"items": items}) } func GetResource(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } row := pool.QueryRow(r.Context(), ` SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.id = $1 AND r.status = 'published' AND r.is_public = TRUE`, id) dto, err := scanResourceRow(row, r, pool) if err != nil { if err == pgx.ErrNoRows { http.NotFound(w, r) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, dto) } func RelatedResources(w http.ResponseWriter, r *http.Request) { pool := poolFrom(r) idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } var catID int if err := pool.QueryRow(r.Context(), `SELECT category_id FROM resources WHERE id = $1`, id).Scan(&catID); err != nil { http.NotFound(w, r) return } rows, err := pool.Query(r.Context(), ` SELECT r.id, `+resourceI18nColsSQL+`, r.type, COALESCE(r.language,'zh'), r.category_id, c.slug, `+categoryNameColsSQL+`, COALESCE(r.cover_image,''), COALESCE(r.file_url,''), COALESCE(r.preview_url,''), COALESCE(r.external_url,''), COALESCE(r.badge_label,''), r.is_downloadable, r.is_recommended, r.published_at, r.updated_at FROM resources r JOIN categories c ON c.id = r.category_id WHERE r.status = 'published' AND r.is_public = TRUE AND r.category_id = $1 AND r.id <> $2 ORDER BY r.updated_at DESC LIMIT 8`, catID, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() items, err := readResourceRows(rows, r, pool) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"items": items}) } func LogSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } var body struct { Query string `json:"query"` } if err := jsonDecode(r, &body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } q := strings.TrimSpace(body.Query) if q == "" { writeJSON(w, map[string]any{"ok": true}) return } pool := poolFrom(r) _, _ = pool.Exec(r.Context(), `INSERT INTO search_logs (query) VALUES ($1)`, q) writeJSON(w, map[string]any{"ok": true}) } func IncrView(w http.ResponseWriter, r *http.Request) { incrMetric(w, r, "view_count") } func IncrDownload(w http.ResponseWriter, r *http.Request) { incrMetric(w, r, "download_count") } func IncrShare(w http.ResponseWriter, r *http.Request) { incrMetric(w, r, "share_count") } func FavoriteDelta(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } var body struct { Add bool `json:"add"` } if err := jsonDecode(r, &body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } pool := poolFrom(r) idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } var q string if body.Add { q = `UPDATE resources SET favorite_count = favorite_count + 1 WHERE id = $1` } else { q = `UPDATE resources SET favorite_count = GREATEST(0, favorite_count - 1) WHERE id = $1` } if _, err := pool.Exec(r.Context(), q, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } func incrMetric(w http.ResponseWriter, r *http.Request, col string) { pool := poolFrom(r) idStr := chi.URLParam(r, "id") id, err := uuid.Parse(idStr) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } // col is whitelisted switch col { case "view_count", "download_count", "share_count": default: http.Error(w, "bad metric", http.StatusBadRequest) return } _, err = pool.Exec(r.Context(), `UPDATE resources SET `+col+` = `+col+` + 1 WHERE id = $1`, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } func readResourceRows(rows pgx.Rows, r *http.Request, pool *pgxpool.Pool) ([]ResourceDTO, error) { list := make([]ResourceDTO, 0) for rows.Next() { dto, err := scanResourceRow(rows, r, pool) if err != nil { return nil, err } list = append(list, dto) } return list, rows.Err() } func scanResourceRow(scanner interface { Scan(dest ...any) error }, r *http.Request, pool *pgxpool.Pool) (ResourceDTO, error) { var dto ResourceDTO var pubAt *time.Time var updated time.Time var catNameZh, catNameEn, catNameJa, catNameKo, catNameVi, catNameId, catNameMs string var id uuid.UUID var texts resourceTextI18n err := scanner.Scan( &id, &texts.TitleZh, &texts.TitleEn, &texts.TitleJa, &texts.TitleKo, &texts.TitleVi, &texts.TitleId, &texts.TitleMs, &texts.DescZh, &texts.DescEn, &texts.DescJa, &texts.DescKo, &texts.DescVi, &texts.DescId, &texts.DescMs, &texts.BodyZh, &texts.BodyEn, &texts.BodyJa, &texts.BodyKo, &texts.BodyVi, &texts.BodyId, &texts.BodyMs, &dto.Type, &dto.Language, &dto.CategoryID, &dto.CategorySlug, &catNameZh, &catNameEn, &catNameJa, &catNameKo, &catNameVi, &catNameId, &catNameMs, &dto.CoverImage, &dto.FileURL, &dto.PreviewURL, &dto.ExternalURL, &dto.BadgeLabel, &dto.IsDownloadable, &dto.IsRecommended, &pubAt, &updated, ) if err != nil { return dto, err } dto.ID = id.String() dto.Title, dto.Description, dto.BodyText = texts.pick(r) catTexts := categoryTextI18n{NameZh: catNameZh, NameEn: catNameEn, NameJa: catNameJa, NameKo: catNameKo, NameVi: catNameVi, NameId: catNameId, NameMs: catNameMs} dto.CategoryName = catTexts.pickName(r) if pubAt != nil { s := pubAt.UTC().Format(time.RFC3339) dto.PublishedAt = &s } dto.UpdatedAt = updated.UTC().Format(time.RFC3339) if pool != nil { tags, err := loadTagNames(r.Context(), pool, id) if err == nil { dto.Tags = tags } } return dto, nil } func loadTagNames(ctx context.Context, pool *pgxpool.Pool, id uuid.UUID) ([]string, error) { rows, err := pool.Query(ctx, `SELECT t.name FROM resource_tags rt JOIN tags t ON t.id = rt.tag_id WHERE rt.resource_id = $1 ORDER BY t.name`, id) if err != nil { return nil, err } defer rows.Close() out := make([]string, 0) for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() }