Files
Arkie-Library-Backend/internal/handlers/posts_common.go

489 lines
16 KiB
Go
Raw Normal View History

2026-05-25 16:45:33 +08:00
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AttachmentDTO struct {
ID string `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Mime string `json:"mime"`
Filename string `json:"filename"`
SizeBytes int64 `json:"sizeBytes"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
DurationSec *int `json:"durationSec,omitempty"`
PosterURL string `json:"posterUrl,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
}
type PostDTO struct {
ID string `json:"id"`
2026-05-25 23:43:55 +08:00
PostType string `json:"postType"`
CategoryID int `json:"categoryId,omitempty"`
CategorySlug string `json:"categorySlug,omitempty"`
2026-05-26 11:12:22 +08:00
Language string `json:"language"` // UI locale from ?lang= (matches text selection)
SourceLanguage string `json:"sourceLanguage,omitempty"` // DB source metadata (admin input language)
2026-05-25 23:43:55 +08:00
Text string `json:"text,omitempty"`
Localizations map[string]postLocalePayload `json:"localizations"`
Attachments []AttachmentDTO `json:"attachments"`
2026-05-25 16:45:33 +08:00
IsRecommended bool `json:"isRecommended"`
PublishedAt string `json:"publishedAt"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt,omitempty"`
2026-05-26 12:13:19 +08:00
Tags []string `json:"tags"`
2026-05-25 16:45:33 +08:00
}
type PostListResponse struct {
Items []PostDTO `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
}
type attachmentInput struct {
ID string `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Mime string `json:"mime"`
Filename string `json:"filename"`
SizeBytes int64 `json:"sizeBytes"`
Width *int `json:"width"`
Height *int `json:"height"`
DurationSec *int `json:"durationSec"`
PosterURL string `json:"posterUrl"`
ThumbnailURL string `json:"thumbnailUrl"`
SortOrder int `json:"sortOrder"`
}
type adminPost struct {
ID string `json:"id"`
2026-05-25 23:43:55 +08:00
PostType string `json:"postType"`
CategoryID int `json:"categoryId,omitempty"`
2026-05-26 09:01:25 +08:00
CategorySlug string `json:"categorySlug,omitempty"`
2026-05-25 16:45:33 +08:00
Language string `json:"language"`
Text string `json:"text"`
Attachments []attachmentInput `json:"attachments"`
IsPublic bool `json:"isPublic"`
IsRecommended bool `json:"isRecommended"`
SortOrder int `json:"sortOrder"`
Status string `json:"status"`
PublishedAt *string `json:"publishedAt,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Tags []string `json:"tags,omitempty"`
Localizations map[string]postLocalePayload `json:"localizations,omitempty"`
ViewCount int `json:"viewCount,omitempty"`
DownloadCount int `json:"downloadCount,omitempty"`
}
const publicPostWhere = `p.status = 'published' AND p.is_public = TRUE AND (p.published_at IS NULL OR p.published_at <= NOW())`
func parsePublishedAtPtr(s *string) (*time.Time, error) {
if s == nil || strings.TrimSpace(*s) == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*s))
if err != nil {
t, err = time.Parse(time.RFC3339Nano, strings.TrimSpace(*s))
}
if err != nil {
return nil, err
}
utc := t.UTC()
return &utc, nil
}
func resolvePostPublishedAt(pubIn *string, status string, existing *time.Time, isCreate bool) (*time.Time, error) {
if pubIn != nil && strings.TrimSpace(*pubIn) != "" {
return parsePublishedAtPtr(pubIn)
}
if status == "published" {
if isCreate || existing == nil {
t := time.Now().UTC()
return &t, nil
}
return existing, nil
}
return existing, nil
}
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func encodePostCursor(pub time.Time, id uuid.UUID) string {
return pub.UTC().Format(time.RFC3339Nano) + "|" + id.String()
}
func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
parts := strings.SplitN(cursor, "|", 2)
if len(parts) != 2 {
return time.Time{}, uuid.Nil, fmt.Errorf("bad cursor")
}
t, err := time.Parse(time.RFC3339Nano, parts[0])
if err != nil {
t, err = time.Parse(time.RFC3339, parts[0])
}
if err != nil {
return time.Time{}, uuid.Nil, err
}
id, err := uuid.Parse(parts[1])
if err != nil {
return time.Time{}, uuid.Nil, err
}
return t.UTC(), id, nil
}
func postTypeFilterSQL(typ string, args *[]any) string {
2026-05-25 23:43:55 +08:00
raw := strings.ToLower(strings.TrimSpace(typ))
if raw == "" || raw == "all" {
2026-05-25 16:45:33 +08:00
return ""
}
2026-05-25 23:43:55 +08:00
typ = normalizePostType(typ)
*args = append(*args, typ)
n := len(*args)
// Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics.
2026-05-25 16:45:33 +08:00
switch typ {
case "image":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'image')))`, n)
2026-05-25 16:45:33 +08:00
case "video":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%%'))))`, n)
case "music":
return fmt.Sprintf(` AND (p.post_type = $%d OR (COALESCE(p.post_type,'') = 'text' AND EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.mime LIKE 'audio/%%' OR pa.filename ILIKE '%%.mp3' OR pa.filename ILIKE '%%.wav' OR pa.filename ILIKE '%%.m4a'))))`, n)
2026-05-25 16:45:33 +08:00
case "pdf":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%pdf%%' OR pa.filename ILIKE '%%.pdf')))`, n)
2026-05-25 16:45:33 +08:00
case "ppt":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.mime ILIKE '%%presentation%%' OR pa.filename ILIKE '%%.ppt%%' OR pa.filename ILIKE '%%.pptx%%')))`, n)
2026-05-25 16:45:33 +08:00
case "archive":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR EXISTS (
SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = 'document' AND (pa.filename ILIKE '%%.zip%%' OR pa.filename ILIKE '%%.rar%%' OR pa.filename ILIKE '%%.7z%%')))`, n)
2026-05-25 16:45:33 +08:00
case "text":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND p.post_type = $%d`, n)
2026-05-25 16:45:33 +08:00
case "link":
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n)
2026-05-25 16:45:33 +08:00
default:
2026-05-25 23:43:55 +08:00
return fmt.Sprintf(` AND p.post_type = $%d`, n)
2026-05-25 16:45:33 +08:00
}
}
func normalizePostLangFilter(lang string) string {
lang = strings.TrimSpace(lang)
if lang == "" {
return ""
}
return translateNormalizePostLang(lang)
}
func translateNormalizePostLang(lang string) string {
lang = strings.ToLower(lang)
switch {
case strings.HasPrefix(lang, "zh"):
return "zh"
case strings.HasPrefix(lang, "en"):
return "en"
case strings.HasPrefix(lang, "ja"):
return "ja"
case strings.HasPrefix(lang, "ko"):
return "ko"
case strings.HasPrefix(lang, "vi"):
return "vi"
case lang == "id", strings.HasPrefix(lang, "in"):
return "id"
case strings.HasPrefix(lang, "ms"):
return "ms"
default:
return lang
}
}
func validateAdminPost(ap *adminPost) error {
2026-05-26 11:12:22 +08:00
ap.PostType = inferPostTypeFromContent(ap.Text, ap.Attachments)
if !validPostTypes[ap.PostType] {
ap.PostType = "text"
2026-05-25 16:45:33 +08:00
}
if len(ap.Attachments) > maxPostAttachments {
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
}
hasText := strings.TrimSpace(ap.Text) != ""
hasAtt := len(ap.Attachments) > 0
2026-05-25 23:43:55 +08:00
if ap.Status == "published" && !hasText && !hasAtt {
return fmt.Errorf("published post requires text and/or at least one attachment")
2026-05-25 16:45:33 +08:00
}
2026-05-26 09:01:25 +08:00
if ap.Status == "published" && ap.CategoryID <= 0 {
return fmt.Errorf("published post requires categoryId")
}
2026-05-25 16:45:33 +08:00
for i, a := range ap.Attachments {
if strings.TrimSpace(a.URL) == "" {
return fmt.Errorf("attachment %d: url required", i)
}
k := strings.TrimSpace(a.Kind)
if k != "image" && k != "video" && k != "document" {
return fmt.Errorf("attachment %d: invalid kind", i)
}
}
if ap.Status == "" {
ap.Status = "draft"
}
2026-05-26 12:08:39 +08:00
normalized, err := normalizeAdminPostTags(ap.Tags)
if err != nil {
return err
}
ap.Tags = normalized
2026-05-25 16:45:33 +08:00
return nil
}
func loadAttachmentsByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID) (map[uuid.UUID][]AttachmentDTO, error) {
out := make(map[uuid.UUID][]AttachmentDTO)
if len(ids) == 0 {
return out, nil
}
rows, err := pool.Query(ctx, `
SELECT id, post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec,
COALESCE(poster_url,''), COALESCE(thumbnail_url,''), sort_order
FROM post_attachments WHERE post_id = ANY($1)
ORDER BY post_id, sort_order ASC, id ASC`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var aid, pid uuid.UUID
var a AttachmentDTO
var kind, url, mime, filename string
var size int64
var w, h, dur *int
var poster, thumb string
var sort int
if err := rows.Scan(&aid, &pid, &kind, &url, &mime, &filename, &size, &w, &h, &dur, &poster, &thumb, &sort); err != nil {
return nil, err
}
a.ID = aid.String()
a.Kind = kind
a.URL = url
a.Mime = mime
a.Filename = filename
a.SizeBytes = size
a.Width = w
a.Height = h
a.DurationSec = dur
a.PosterURL = poster
a.ThumbnailURL = thumb
out[pid] = append(out[pid], a)
}
return out, rows.Err()
}
2026-05-26 12:08:39 +08:00
func loadPostTagNames(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, lang string) ([]string, error) {
2026-05-25 16:45:33 +08:00
rows, err := pool.Query(ctx, `
2026-05-26 12:08:39 +08:00
SELECT t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''),
COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,''), t.slug
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = $1 ORDER BY t.slug`, postID)
2026-05-25 16:45:33 +08:00
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
2026-05-26 12:08:39 +08:00
var name, en, ja, ko, vi, id, ms, slug string
if err := rows.Scan(&name, &en, &ja, &ko, &vi, &id, &ms, &slug); err != nil {
2026-05-25 16:45:33 +08:00
return nil, err
}
2026-05-26 12:08:39 +08:00
names = append(names, tagDisplayName(name, en, ja, ko, vi, id, ms, lang))
2026-05-25 16:45:33 +08:00
}
return names, rows.Err()
}
2026-05-26 12:13:19 +08:00
func loadPostTagNamesByPostIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID, lang string) (map[uuid.UUID][]string, error) {
out := make(map[uuid.UUID][]string)
if len(ids) == 0 {
return out, nil
}
rows, err := pool.Query(ctx, `
SELECT pt.post_id, t.name, COALESCE(t.name_en,''), COALESCE(t.name_ja,''), COALESCE(t.name_ko,''),
COALESCE(t.name_vi,''), COALESCE(t.name_id,''), COALESCE(t.name_ms,'')
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = ANY($1)
ORDER BY pt.post_id, t.slug`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var pid uuid.UUID
var name, en, ja, ko, vi, idLoc, ms string
if err := rows.Scan(&pid, &name, &en, &ja, &ko, &vi, &idLoc, &ms); err != nil {
return nil, err
}
out[pid] = append(out[pid], tagDisplayName(name, en, ja, ko, vi, idLoc, ms, lang))
}
return out, rows.Err()
}
2026-05-26 12:08:39 +08:00
func replacePostTags(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, tagSlugs []string) error {
2026-05-25 16:45:33 +08:00
_, err := pool.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID)
if err != nil {
return err
}
2026-05-26 12:08:39 +08:00
for _, slug := range tagSlugs {
slug = strings.TrimSpace(slug)
if slug == "" {
2026-05-25 16:45:33 +08:00
continue
}
var tid int
2026-05-26 12:08:39 +08:00
err := pool.QueryRow(ctx, `SELECT id FROM tags WHERE slug = $1`, slug).Scan(&tid)
2026-05-25 16:45:33 +08:00
if err != nil {
2026-05-26 12:08:39 +08:00
continue
2026-05-25 16:45:33 +08:00
}
_, _ = pool.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, tid)
}
return nil
}
func replacePostAttachments(ctx context.Context, pool *pgxpool.Pool, postID uuid.UUID, inputs []attachmentInput) error {
_, err := pool.Exec(ctx, `DELETE FROM post_attachments WHERE post_id = $1`, postID)
if err != nil {
return err
}
for i, in := range inputs {
kind := strings.TrimSpace(in.Kind)
if kind == "" {
kind = classifyAttachmentKind(in.Mime, in.Filename)
}
sort := in.SortOrder
if sort == 0 {
sort = i
}
_, err := pool.Exec(ctx, `
INSERT INTO post_attachments (post_id, kind, url, mime, filename, size_bytes, width, height, duration_sec, poster_url, thumbnail_url, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
postID, kind, strings.TrimSpace(in.URL), nullIfEmptyStr(in.Mime, "application/octet-stream"),
nullIfEmptyStr(in.Filename, "file"), in.SizeBytes,
in.Width, in.Height, in.DurationSec,
nullIfEmpty(in.PosterURL), nullIfEmpty(in.ThumbnailURL), sort)
if err != nil {
return err
}
}
return nil
}
func classifyAttachmentKind(mime, filename string) string {
m := strings.ToLower(mime)
if strings.HasPrefix(m, "image/") {
return "image"
}
if strings.HasPrefix(m, "video/") {
return "video"
}
fn := strings.ToLower(filename)
if strings.HasSuffix(fn, ".jpg") || strings.HasSuffix(fn, ".jpeg") || strings.HasSuffix(fn, ".png") || strings.HasSuffix(fn, ".gif") || strings.HasSuffix(fn, ".webp") {
return "image"
}
if strings.HasSuffix(fn, ".mp4") || strings.HasSuffix(fn, ".mov") || strings.HasSuffix(fn, ".webm") {
return "video"
}
return "document"
}
2026-05-26 09:01:25 +08:00
func postCategoryIDArg(id int) any {
if id <= 0 {
return nil
}
return id
}
2026-05-25 16:45:33 +08:00
func nullIfEmptyStr(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
var dto PostDTO
var texts postTextI18n
var id uuid.UUID
2026-05-25 23:43:55 +08:00
var catID *int
var slug *string
2026-05-26 11:12:22 +08:00
var sourceLang, postType string
2026-05-25 16:45:33 +08:00
var pub, updated, created *time.Time
err := row.Scan(
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
2026-05-26 11:12:22 +08:00
&sourceLang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
2026-05-25 16:45:33 +08:00
)
if err != nil {
return dto, texts, err
}
dto.ID = id.String()
2026-05-25 23:43:55 +08:00
dto.PostType = normalizePostType(postType)
if catID != nil {
dto.CategoryID = *catID
}
if slug != nil {
dto.CategorySlug = *slug
}
2026-05-26 11:12:22 +08:00
dto.SourceLanguage = sourceLang
dto.Language = requestLangCode(r)
2026-05-25 16:45:33 +08:00
dto.Text = texts.pick(r)
2026-05-25 23:43:55 +08:00
dto.Localizations = texts.toLocalizations()
2026-05-25 16:45:33 +08:00
if pub != nil {
dto.PublishedAt = pub.UTC().Format(time.RFC3339)
} else if created != nil {
dto.PublishedAt = created.UTC().Format(time.RFC3339)
}
if updated != nil {
dto.UpdatedAt = updated.UTC().Format(time.RFC3339)
}
if created != nil {
dto.CreatedAt = created.UTC().Format(time.RFC3339)
}
return dto, texts, nil
}
func postSelectBase() string {
return `
2026-05-25 23:43:55 +08:00
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'),
p.category_id, c.slug,
2026-05-25 16:45:33 +08:00
p.is_recommended, p.published_at, p.updated_at, p.created_at
2026-05-25 23:43:55 +08:00
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id`
}
func postsFromClause() string {
return `FROM posts p LEFT JOIN categories c ON c.id = p.category_id WHERE 1=1`
2026-05-25 16:45:33 +08:00
}
func postLimitDef(r *http.Request, def, max int) int {
limit := atoiDef(r.URL.Query().Get("limit"), def)
if limit > max {
limit = max
}
return limit
}
func postLanguageFilterSQL(lang string, args *[]any) string {
norm := normalizePostLangFilter(lang)
if norm == "" {
return ""
}
*args = append(*args, norm)
return fmt.Sprintf(` AND p.language = $%d`, len(*args))
}