writekit/internal/tenant/posts.go

482 lines
14 KiB
Go
Raw Normal View History

2026-01-09 00:16:46 +02:00
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
}