|
|
|
|
@@ -28,11 +28,13 @@ type AttachmentDTO struct {
|
|
|
|
|
|
|
|
|
|
type PostDTO struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
CategoryID int `json:"categoryId"`
|
|
|
|
|
CategorySlug string `json:"categorySlug"`
|
|
|
|
|
Language string `json:"language"`
|
|
|
|
|
Text string `json:"text,omitempty"`
|
|
|
|
|
Attachments []AttachmentDTO `json:"attachments"`
|
|
|
|
|
PostType string `json:"postType"`
|
|
|
|
|
CategoryID int `json:"categoryId,omitempty"`
|
|
|
|
|
CategorySlug string `json:"categorySlug,omitempty"`
|
|
|
|
|
Language string `json:"language"`
|
|
|
|
|
Text string `json:"text,omitempty"`
|
|
|
|
|
Localizations map[string]postLocalePayload `json:"localizations"`
|
|
|
|
|
Attachments []AttachmentDTO `json:"attachments"`
|
|
|
|
|
IsRecommended bool `json:"isRecommended"`
|
|
|
|
|
PublishedAt string `json:"publishedAt"`
|
|
|
|
|
UpdatedAt string `json:"updatedAt"`
|
|
|
|
|
@@ -62,7 +64,8 @@ type attachmentInput struct {
|
|
|
|
|
|
|
|
|
|
type adminPost struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
CategoryID int `json:"categoryId"`
|
|
|
|
|
PostType string `json:"postType"`
|
|
|
|
|
CategoryID int `json:"categoryId,omitempty"`
|
|
|
|
|
Language string `json:"language"`
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
Attachments []attachmentInput `json:"attachments"`
|
|
|
|
|
@@ -141,30 +144,39 @@ func decodePostCursor(cursor string) (time.Time, uuid.UUID, error) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func postTypeFilterSQL(typ string, args *[]any) string {
|
|
|
|
|
typ = strings.ToLower(strings.TrimSpace(typ))
|
|
|
|
|
if typ == "" || typ == "all" {
|
|
|
|
|
raw := strings.ToLower(strings.TrimSpace(typ))
|
|
|
|
|
if raw == "" || raw == "all" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
typ = normalizePostType(typ)
|
|
|
|
|
*args = append(*args, typ)
|
|
|
|
|
n := len(*args)
|
|
|
|
|
// Primary: admin-selected post_type; legacy rows fall back via OR attachment heuristics.
|
|
|
|
|
switch typ {
|
|
|
|
|
case "image":
|
|
|
|
|
*args = append(*args, "image")
|
|
|
|
|
n := len(*args)
|
|
|
|
|
return fmt.Sprintf(` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND pa.kind = $%d)`, n)
|
|
|
|
|
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)
|
|
|
|
|
case "video":
|
|
|
|
|
return ` AND EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id AND (pa.kind = 'video' OR pa.mime LIKE 'video/%'))`
|
|
|
|
|
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)
|
|
|
|
|
case "pdf":
|
|
|
|
|
return ` AND 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'))`
|
|
|
|
|
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)
|
|
|
|
|
case "ppt":
|
|
|
|
|
return ` AND 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%' OR pa.filename ILIKE '%.key%'))`
|
|
|
|
|
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)
|
|
|
|
|
case "archive":
|
|
|
|
|
return ` AND 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%'))`
|
|
|
|
|
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)
|
|
|
|
|
case "text":
|
|
|
|
|
return ` AND NOT EXISTS (SELECT 1 FROM post_attachments pa WHERE pa.post_id = p.id)
|
|
|
|
|
AND (COALESCE(p.text_zh,'') <> '' OR COALESCE(p.text_en,'') <> '' OR COALESCE(p.text_ja,'') <> '' OR COALESCE(p.text_ko,'') <> '' OR COALESCE(p.text_vi,'') <> '' OR COALESCE(p.text_id,'') <> '' OR COALESCE(p.text_ms,'') <> '')`
|
|
|
|
|
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
|
|
|
|
case "link":
|
|
|
|
|
return ` AND (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ja ~* 'https?://' OR p.text_ko ~* 'https?://' OR p.text_vi ~* 'https?://' OR p.text_id ~* 'https?://' OR p.text_ms ~* 'https?://')`
|
|
|
|
|
return fmt.Sprintf(` AND (p.post_type = $%d OR (p.text_zh ~* 'https?://' OR p.text_en ~* 'https?://' OR p.text_ms ~* 'https?://'))`, n)
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
return fmt.Sprintf(` AND p.post_type = $%d`, n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -199,16 +211,17 @@ func translateNormalizePostLang(lang string) string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateAdminPost(ap *adminPost) error {
|
|
|
|
|
if ap.CategoryID <= 0 {
|
|
|
|
|
return fmt.Errorf("categoryId required")
|
|
|
|
|
ap.PostType = normalizePostType(ap.PostType)
|
|
|
|
|
if ap.PostType == "" || !validPostTypes[ap.PostType] {
|
|
|
|
|
return fmt.Errorf("postType required (image, video, music, ppt, pdf, link, text, archive)")
|
|
|
|
|
}
|
|
|
|
|
if len(ap.Attachments) > maxPostAttachments {
|
|
|
|
|
return fmt.Errorf("too many attachments (max %d)", maxPostAttachments)
|
|
|
|
|
}
|
|
|
|
|
hasText := strings.TrimSpace(ap.Text) != ""
|
|
|
|
|
hasAtt := len(ap.Attachments) > 0
|
|
|
|
|
if !hasText && !hasAtt {
|
|
|
|
|
return fmt.Errorf("post requires text and/or at least one attachment")
|
|
|
|
|
if ap.Status == "published" && !hasText && !hasAtt {
|
|
|
|
|
return fmt.Errorf("published post requires text and/or at least one attachment")
|
|
|
|
|
}
|
|
|
|
|
for i, a := range ap.Attachments {
|
|
|
|
|
if strings.TrimSpace(a.URL) == "" {
|
|
|
|
|
@@ -363,22 +376,28 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
|
|
|
|
|
var dto PostDTO
|
|
|
|
|
var texts postTextI18n
|
|
|
|
|
var id uuid.UUID
|
|
|
|
|
var catID int
|
|
|
|
|
var slug string
|
|
|
|
|
var lang string
|
|
|
|
|
var catID *int
|
|
|
|
|
var slug *string
|
|
|
|
|
var lang, postType string
|
|
|
|
|
var pub, updated, created *time.Time
|
|
|
|
|
err := row.Scan(
|
|
|
|
|
&id, &texts.TextZh, &texts.TextEn, &texts.TextJa, &texts.TextKo, &texts.TextVi, &texts.TextId, &texts.TextMs,
|
|
|
|
|
&lang, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
|
|
|
|
|
&lang, &postType, &catID, &slug, &dto.IsRecommended, &pub, &updated, &created,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return dto, texts, err
|
|
|
|
|
}
|
|
|
|
|
dto.ID = id.String()
|
|
|
|
|
dto.CategoryID = catID
|
|
|
|
|
dto.CategorySlug = slug
|
|
|
|
|
dto.PostType = normalizePostType(postType)
|
|
|
|
|
if catID != nil {
|
|
|
|
|
dto.CategoryID = *catID
|
|
|
|
|
}
|
|
|
|
|
if slug != nil {
|
|
|
|
|
dto.CategorySlug = *slug
|
|
|
|
|
}
|
|
|
|
|
dto.Language = lang
|
|
|
|
|
dto.Text = texts.pick(r)
|
|
|
|
|
dto.Localizations = texts.toLocalizations()
|
|
|
|
|
if pub != nil {
|
|
|
|
|
dto.PublishedAt = pub.UTC().Format(time.RFC3339)
|
|
|
|
|
} else if created != nil {
|
|
|
|
|
@@ -395,9 +414,15 @@ func scanPostRow(r *http.Request, row pgx.Row) (PostDTO, postTextI18n, error) {
|
|
|
|
|
|
|
|
|
|
func postSelectBase() string {
|
|
|
|
|
return `
|
|
|
|
|
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), p.category_id, c.slug,
|
|
|
|
|
SELECT p.id, ` + postI18nColsSQL + `, COALESCE(p.language,'zh'), COALESCE(p.post_type,'text'),
|
|
|
|
|
p.category_id, c.slug,
|
|
|
|
|
p.is_recommended, p.published_at, p.updated_at, p.created_at
|
|
|
|
|
FROM posts p JOIN categories c ON c.id = p.category_id`
|
|
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func postLimitDef(r *http.Request, def, max int) int {
|
|
|
|
|
|