package tenant import ( "context" "database/sql" "encoding/json" "time" "github.com/google/uuid" ) func (q *Queries) ListPosts(ctx context.Context, includeUnpublished bool) ([]Post, error) { query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts ORDER BY COALESCE(published_at, created_at) DESC` if !includeUnpublished { query = `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts WHERE is_published = 1 ORDER BY COALESCE(published_at, created_at) DESC` } rows, err := q.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var posts []Post for rows.Next() { p, err := scanPost(rows) if err != nil { return nil, err } posts = append(posts, p) } return posts, rows.Err() } type ListPostsOptions struct { Limit int Offset int Tag string } type ListPostsResult struct { Posts []Post Total int } func (q *Queries) ListPostsPaginated(ctx context.Context, opts ListPostsOptions) (*ListPostsResult, error) { if opts.Limit <= 0 { opts.Limit = 20 } if opts.Limit > 100 { opts.Limit = 100 } var args []any where := "WHERE is_published = 1" if opts.Tag != "" { where += " AND tags LIKE ?" args = append(args, "%\""+opts.Tag+"\"%") } var total int countQuery := "SELECT COUNT(*) FROM posts " + where err := q.db.QueryRowContext(ctx, countQuery, args...).Scan(&total) if err != nil { return nil, err } query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts ` + where + ` ORDER BY COALESCE(published_at, created_at) DESC LIMIT ? OFFSET ?` args = append(args, opts.Limit, opts.Offset) rows, err := q.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var posts []Post for rows.Next() { p, err := scanPost(rows) if err != nil { return nil, err } posts = append(posts, p) } return &ListPostsResult{Posts: posts, Total: total}, rows.Err() } func (q *Queries) GetPost(ctx context.Context, slug string) (*Post, error) { row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts WHERE slug = ?`, slug) p, err := scanPost(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &p, nil } func (q *Queries) GetPostByID(ctx context.Context, id string) (*Post, error) { row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts WHERE id = ?`, id) p, err := scanPost(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &p, nil } func (q *Queries) GetPostByAlias(ctx context.Context, alias string) (*Post, error) { pattern := "%\"" + alias + "\"%" row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at FROM posts WHERE aliases LIKE ? LIMIT 1`, pattern) p, err := scanPost(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &p, nil } func (q *Queries) CreatePost(ctx context.Context, p *Post) error { if p.ID == "" { p.ID = uuid.NewString() } now := time.Now().UTC().Format(time.RFC3339) _, err := q.db.ExecContext(ctx, `INSERT INTO posts (id, slug, title, description, tags, cover_image, content_md, content_html, is_published, members_only, published_at, updated_at, aliases, created_at, modified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage), nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly), timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, now) if err != nil { return err } q.IndexPost(ctx, p) return nil } func (q *Queries) UpdatePost(ctx context.Context, p *Post) error { now := time.Now().UTC().Format(time.RFC3339) _, err := q.db.ExecContext(ctx, `UPDATE posts SET slug = ?, title = ?, description = ?, tags = ?, cover_image = ?, content_md = ?, content_html = ?, is_published = ?, members_only = ?, published_at = ?, updated_at = ?, aliases = ?, modified_at = ? WHERE id = ?`, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage), nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly), timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, p.ID) if err != nil { return err } q.IndexPost(ctx, p) return nil } func (q *Queries) DeletePost(ctx context.Context, id string) error { post, _ := q.GetPostByID(ctx, id) _, err := q.db.ExecContext(ctx, `DELETE FROM posts WHERE id = ?`, id) if err == nil && post != nil { q.RemoveFromIndex(ctx, post.Slug, "post") } return err } func (q *Queries) GetDraft(ctx context.Context, postID string) (*PostDraft, error) { row := q.db.QueryRowContext(ctx, `SELECT post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at FROM post_drafts WHERE post_id = ?`, postID) d, err := scanDraft(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &d, nil } func (q *Queries) SaveDraft(ctx context.Context, d *PostDraft) error { now := time.Now().UTC().Format(time.RFC3339) _, err := q.db.ExecContext(ctx, `INSERT INTO post_drafts (post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(post_id) DO UPDATE SET slug = excluded.slug, title = excluded.title, description = excluded.description, tags = excluded.tags, cover_image = excluded.cover_image, members_only = excluded.members_only, content_md = excluded.content_md, content_html = excluded.content_html, modified_at = excluded.modified_at`, d.PostID, d.Slug, nullStr(d.Title), nullStr(d.Description), jsonStr(d.Tags), nullStr(d.CoverImage), boolToInt(d.MembersOnly), nullStr(d.ContentMD), nullStr(d.ContentHTML), now) return err } func (q *Queries) DeleteDraft(ctx context.Context, postID string) error { _, err := q.db.ExecContext(ctx, `DELETE FROM post_drafts WHERE post_id = ?`, postID) return err } func (q *Queries) HasDraft(ctx context.Context, postID string) (bool, error) { var count int64 err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM post_drafts WHERE post_id = ?`, postID).Scan(&count) return count > 0, err } func (q *Queries) CreateVersion(ctx context.Context, p *Post) error { now := time.Now().UTC().Format(time.RFC3339) _, err := q.db.ExecContext(ctx, `INSERT INTO post_versions (post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage), nullStr(p.ContentMD), nullStr(p.ContentHTML), now) return err } func (q *Queries) ListVersions(ctx context.Context, postID string) ([]PostVersion, error) { rows, err := q.db.QueryContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at FROM post_versions WHERE post_id = ? ORDER BY created_at DESC`, postID) if err != nil { return nil, err } defer rows.Close() var versions []PostVersion for rows.Next() { v, err := scanVersion(rows) if err != nil { return nil, err } versions = append(versions, v) } return versions, rows.Err() } func (q *Queries) GetVersion(ctx context.Context, versionID int64) (*PostVersion, error) { row := q.db.QueryRowContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at FROM post_versions WHERE id = ?`, versionID) v, err := scanVersion(row) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &v, nil } func (q *Queries) PruneVersions(ctx context.Context, postID string, keepCount int) error { _, err := q.db.ExecContext(ctx, `DELETE FROM post_versions AS pv WHERE pv.post_id = ?1 AND pv.id NOT IN ( SELECT sub.id FROM post_versions AS sub WHERE sub.post_id = ?1 ORDER BY sub.created_at DESC LIMIT ?2 )`, postID, keepCount) return err } func (q *Queries) Publish(ctx context.Context, postID string) error { post, err := q.GetPostByID(ctx, postID) if err != nil || post == nil { return err } draft, err := q.GetDraft(ctx, postID) if err != nil { return err } now := time.Now().UTC() if draft != nil { if draft.Slug != post.Slug { if !contains(post.Aliases, post.Slug) { post.Aliases = append(post.Aliases, post.Slug) } } post.Slug = draft.Slug post.Title = draft.Title post.Description = draft.Description post.Tags = draft.Tags post.CoverImage = draft.CoverImage post.MembersOnly = draft.MembersOnly post.ContentMD = draft.ContentMD post.ContentHTML = draft.ContentHTML } if post.PublishedAt == nil { post.PublishedAt = &now } post.UpdatedAt = &now post.IsPublished = true if err := q.UpdatePost(ctx, post); err != nil { return err } if err := q.CreateVersion(ctx, post); err != nil { return err } if err := q.DeleteDraft(ctx, postID); err != nil { return err } return q.PruneVersions(ctx, postID, 10) } func (q *Queries) Unpublish(ctx context.Context, postID string) error { now := time.Now().UTC().Format(time.RFC3339) _, err := q.db.ExecContext(ctx, `UPDATE posts SET is_published = 0, modified_at = ? WHERE id = ?`, now, postID) return err } func (q *Queries) RestoreVersion(ctx context.Context, postID string, versionID int64) error { version, err := q.GetVersion(ctx, versionID) if err != nil || version == nil { return err } return q.SaveDraft(ctx, &PostDraft{ PostID: postID, Slug: version.Slug, Title: version.Title, Description: version.Description, Tags: version.Tags, CoverImage: version.CoverImage, ContentMD: version.ContentMD, ContentHTML: version.ContentHTML, }) } type scanner interface { Scan(dest ...any) error } func scanPost(s scanner) (Post, error) { var p Post var title, desc, tags, cover, md, html, pubAt, updAt, aliases, createdAt, modAt sql.NullString var isPub, memOnly sql.NullInt64 err := s.Scan(&p.ID, &p.Slug, &title, &desc, &tags, &cover, &md, &html, &isPub, &memOnly, &pubAt, &updAt, &aliases, &createdAt, &modAt) if err != nil { return p, err } p.Title = title.String p.Description = desc.String p.Tags = parseJSON[[]string](tags.String) p.CoverImage = cover.String p.ContentMD = md.String p.ContentHTML = html.String p.IsPublished = isPub.Int64 == 1 p.MembersOnly = memOnly.Int64 == 1 p.PublishedAt = parseTimePtr(pubAt.String) p.UpdatedAt = parseTimePtr(updAt.String) p.Aliases = parseJSON[[]string](aliases.String) p.CreatedAt = parseTime(createdAt.String) p.ModifiedAt = parseTime(modAt.String) return p, nil } func scanDraft(s scanner) (PostDraft, error) { var d PostDraft var title, desc, tags, cover, md, html, modAt sql.NullString var memOnly sql.NullInt64 err := s.Scan(&d.PostID, &d.Slug, &title, &desc, &tags, &cover, &memOnly, &md, &html, &modAt) if err != nil { return d, err } d.Title = title.String d.Description = desc.String d.Tags = parseJSON[[]string](tags.String) d.CoverImage = cover.String d.MembersOnly = memOnly.Int64 == 1 d.ContentMD = md.String d.ContentHTML = html.String d.ModifiedAt = parseTime(modAt.String) return d, nil } func scanVersion(s scanner) (PostVersion, error) { var v PostVersion var title, desc, tags, cover, md, html, createdAt sql.NullString err := s.Scan(&v.ID, &v.PostID, &v.Slug, &title, &desc, &tags, &cover, &md, &html, &createdAt) if err != nil { return v, err } v.Title = title.String v.Description = desc.String v.Tags = parseJSON[[]string](tags.String) v.CoverImage = cover.String v.ContentMD = md.String v.ContentHTML = html.String v.CreatedAt = parseTime(createdAt.String) return v, nil } func nullStr(s string) sql.NullString { return sql.NullString{String: s, Valid: s != ""} } func boolToInt(b bool) int { if b { return 1 } return 0 } func jsonStr[T any](v T) sql.NullString { b, _ := json.Marshal(v) s := string(b) if s == "null" || s == "[]" { return sql.NullString{} } return sql.NullString{String: s, Valid: true} } func parseJSON[T any](s string) T { var v T if s != "" { json.Unmarshal([]byte(s), &v) } return v } func parseTime(s string) time.Time { if s == "" { return time.Time{} } for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05"} { if t, err := time.Parse(layout, s); err == nil { return t } } return time.Time{} } func parseTimePtr(s string) *time.Time { t := parseTime(s) if t.IsZero() { return nil } return &t } func timeToStr(t *time.Time) sql.NullString { if t == nil { return sql.NullString{} } return sql.NullString{String: t.UTC().Format(time.RFC3339), Valid: true} } func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false }