481 lines
14 KiB
Go
481 lines
14 KiB
Go
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
|
|
}
|