Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
823 lines
21 KiB
Go
823 lines
21 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
type Store struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
type PostListOptions struct {
|
|
Status PostStatus
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
type PostInput struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
BodyMarkdown string `json:"bodyMarkdown"`
|
|
Status PostStatus `json:"status"`
|
|
Tags []string `json:"tags"`
|
|
Cover string `json:"cover"`
|
|
SlugSource string `json:"slugSource"`
|
|
SlugLocked bool `json:"slugLocked"`
|
|
CreatedAt *time.Time `json:"createdAt"`
|
|
}
|
|
|
|
func NewStore(db *pgxpool.Pool) *Store {
|
|
return &Store{db: db}
|
|
}
|
|
|
|
func (s *Store) ListPosts(ctx context.Context, opts PostListOptions) ([]Post, error) {
|
|
limit := opts.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
offset := opts.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
args := []any{limit, offset}
|
|
where := "deleted_at IS NULL"
|
|
if opts.Status != "" {
|
|
if !ValidPostStatus(opts.Status) {
|
|
return nil, fmt.Errorf("invalid post status: %s", opts.Status)
|
|
}
|
|
args = append(args, opts.Status)
|
|
where += fmt.Sprintf(" AND status = $%d", len(args))
|
|
}
|
|
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
|
FROM posts
|
|
WHERE `+where+`
|
|
ORDER BY updated_at DESC
|
|
LIMIT $1 OFFSET $2`, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list posts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []Post
|
|
for rows.Next() {
|
|
post, err := scanPost(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
posts = append(posts, post)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list posts rows: %w", err)
|
|
}
|
|
if err := s.attachTags(ctx, posts); err != nil {
|
|
return nil, err
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *Store) CountPosts(ctx context.Context, status PostStatus) (int, error) {
|
|
args := []any{}
|
|
where := "deleted_at IS NULL"
|
|
if status != "" {
|
|
if !ValidPostStatus(status) {
|
|
return 0, fmt.Errorf("invalid post status: %s", status)
|
|
}
|
|
args = append(args, status)
|
|
where += " AND status = $1"
|
|
}
|
|
|
|
var total int
|
|
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE `+where, args...).Scan(&total); err != nil {
|
|
return 0, fmt.Errorf("count posts: %w", err)
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (s *Store) GetPost(ctx context.Context, id string) (Post, error) {
|
|
post, err := scanPost(s.db.QueryRow(ctx, `
|
|
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
|
FROM posts
|
|
WHERE id = $1 AND deleted_at IS NULL`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Post{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Post{}, fmt.Errorf("get post: %w", err)
|
|
}
|
|
posts := []Post{post}
|
|
if err := s.attachTags(ctx, posts); err != nil {
|
|
return Post{}, err
|
|
}
|
|
post = posts[0]
|
|
return post, nil
|
|
}
|
|
|
|
func (s *Store) UniqueSlug(ctx context.Context, base string, excludePostID string) (string, error) {
|
|
base = sanitizeSlug(base)
|
|
if base == "" {
|
|
return "", errors.New("slug is empty")
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
candidate := base
|
|
if i > 0 {
|
|
candidate = fmt.Sprintf("%s-%d", base, i+1)
|
|
}
|
|
exists, err := s.slugExists(ctx, candidate, excludePostID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !exists {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("could not find available slug for %q", base)
|
|
}
|
|
|
|
func (s *Store) slugExists(ctx context.Context, slug string, excludePostID string) (bool, error) {
|
|
var exists bool
|
|
var err error
|
|
if strings.TrimSpace(excludePostID) != "" {
|
|
err = s.db.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM posts
|
|
WHERE slug = $1 AND id::text <> $2
|
|
)`, slug, excludePostID).Scan(&exists)
|
|
} else {
|
|
err = s.db.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM posts
|
|
WHERE slug = $1
|
|
)`, slug).Scan(&exists)
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("check slug exists: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (s *Store) CreatePost(ctx context.Context, input PostInput) (Post, error) {
|
|
if err := validatePostInput(input, true); err != nil {
|
|
return Post{}, err
|
|
}
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return Post{}, fmt.Errorf("begin create post: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
post, err := insertPost(ctx, tx, input)
|
|
if err != nil {
|
|
return Post{}, err
|
|
}
|
|
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
|
return Post{}, err
|
|
}
|
|
post.Tags = normalizeTagNames(input.Tags)
|
|
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
|
return Post{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Post{}, fmt.Errorf("commit create post: %w", err)
|
|
}
|
|
return post, nil
|
|
}
|
|
|
|
func (s *Store) UpdatePost(ctx context.Context, id string, input PostInput) (Post, error) {
|
|
if err := validatePostInput(input, false); err != nil {
|
|
return Post{}, err
|
|
}
|
|
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return Post{}, fmt.Errorf("begin update post: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var currentStatus PostStatus
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT status
|
|
FROM posts
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
FOR UPDATE`, id).Scan(¤tStatus)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Post{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Post{}, fmt.Errorf("lock post: %w", err)
|
|
}
|
|
if err := ValidatePostStatusTransition(currentStatus, input.Status); err != nil {
|
|
return Post{}, err
|
|
}
|
|
post, err := updatePost(ctx, tx, id, input)
|
|
if err != nil {
|
|
return Post{}, err
|
|
}
|
|
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
|
return Post{}, err
|
|
}
|
|
post.Tags = normalizeTagNames(input.Tags)
|
|
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
|
return Post{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Post{}, fmt.Errorf("commit update post: %w", err)
|
|
}
|
|
return post, nil
|
|
}
|
|
|
|
func (s *Store) CreateManualBuildJob(ctx context.Context, postID string) (BuildJob, error) {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return BuildJob{}, fmt.Errorf("begin create build job: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var exists bool
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM posts
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
)`, postID).Scan(&exists); err != nil {
|
|
return BuildJob{}, fmt.Errorf("check post for build: %w", err)
|
|
}
|
|
if !exists {
|
|
return BuildJob{}, ErrNotFound
|
|
}
|
|
|
|
job, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &postID)
|
|
if err != nil {
|
|
return BuildJob{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return BuildJob{}, fmt.Errorf("commit create build job: %w", err)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (s *Store) DeletePost(ctx context.Context, id string) (*BuildJob, error) {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin delete post: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var currentStatus PostStatus
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT status
|
|
FROM posts
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
FOR UPDATE`, id).Scan(¤tStatus)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lock post: %w", err)
|
|
}
|
|
|
|
commandTag, err := tx.Exec(ctx, `
|
|
UPDATE posts
|
|
SET status = 'deleted', deleted_at = now(), updated_at = now()
|
|
WHERE id = $1 AND deleted_at IS NULL`, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("delete post: %w", err)
|
|
}
|
|
if commandTag.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
var job *BuildJob
|
|
if IsPublicPostStatus(currentStatus) {
|
|
created, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
job = &created
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit delete post: %w", err)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (s *Store) PublishPost(ctx context.Context, id string) (Post, BuildJob, error) {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return Post{}, BuildJob{}, fmt.Errorf("begin publish post: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var current Post
|
|
current, err = scanPost(tx.QueryRow(ctx, `
|
|
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
|
FROM posts
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
FOR UPDATE`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Post{}, BuildJob{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Post{}, BuildJob{}, fmt.Errorf("lock post: %w", err)
|
|
}
|
|
if err := ValidatePostStatusTransition(current.Status, PostStatusPublished); err != nil {
|
|
return Post{}, BuildJob{}, err
|
|
}
|
|
|
|
post, err := scanPost(tx.QueryRow(ctx, `
|
|
UPDATE posts
|
|
SET status = 'published',
|
|
version = version + 1,
|
|
published_at = COALESCE(published_at, now()),
|
|
updated_at = now()
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`, id))
|
|
if err != nil {
|
|
return Post{}, BuildJob{}, fmt.Errorf("publish post: %w", err)
|
|
}
|
|
if err := insertPostVersion(ctx, tx, post, VersionReasonPublish); err != nil {
|
|
return Post{}, BuildJob{}, err
|
|
}
|
|
|
|
job, err := insertBuildJob(ctx, tx, BuildJobTriggerPublish, &post.ID)
|
|
if err != nil {
|
|
return Post{}, BuildJob{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Post{}, BuildJob{}, fmt.Errorf("commit publish post: %w", err)
|
|
}
|
|
posts := []Post{post}
|
|
if err := s.attachTags(ctx, posts); err != nil {
|
|
return Post{}, BuildJob{}, err
|
|
}
|
|
post = posts[0]
|
|
return post, job, nil
|
|
}
|
|
|
|
func (s *Store) GetBuildJob(ctx context.Context, id string) (BuildJob, error) {
|
|
job, err := scanBuildJob(s.db.QueryRow(ctx, `
|
|
SELECT id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by
|
|
FROM build_jobs
|
|
WHERE id = $1`, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return BuildJob{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return BuildJob{}, fmt.Errorf("get build job: %w", err)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
|
|
created, err := scanAsset(s.db.QueryRow(ctx, `
|
|
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (path)
|
|
DO UPDATE SET original_name = excluded.original_name,
|
|
mime_type = excluded.mime_type,
|
|
size_bytes = excluded.size_bytes,
|
|
sha256 = excluded.sha256
|
|
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
|
|
asset.Path,
|
|
asset.OriginalName,
|
|
asset.MimeType,
|
|
asset.SizeBytes,
|
|
asset.SHA256,
|
|
))
|
|
if err != nil {
|
|
return Asset{}, fmt.Errorf("create asset: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
|
FROM posts
|
|
WHERE status IN ('published', 'archived') AND deleted_at IS NULL
|
|
ORDER BY published_at DESC NULLS LAST, updated_at DESC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("published posts for export: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []Post
|
|
for rows.Next() {
|
|
post, err := scanPost(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
posts = append(posts, post)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("published posts rows: %w", err)
|
|
}
|
|
if err := s.attachTags(ctx, posts); err != nil {
|
|
return nil, err
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *Store) MarkBuildJobRunning(ctx context.Context, id string) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
UPDATE build_jobs
|
|
SET status = 'running', started_at = now()
|
|
WHERE id = $1 AND status = 'queued'`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("mark build job running: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) MarkBuildJobSuccess(ctx context.Context, id string, log string) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
UPDATE build_jobs
|
|
SET status = 'success', finished_at = now(), log = $2, error = ''
|
|
WHERE id = $1`, id, log)
|
|
if err != nil {
|
|
return fmt.Errorf("mark build job success: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) MarkBuildJobFailed(ctx context.Context, id string, log string, message string) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
UPDATE build_jobs
|
|
SET status = 'failed', finished_at = now(), log = $2, error = $3
|
|
WHERE id = $1`, id, log, message)
|
|
if err != nil {
|
|
return fmt.Errorf("mark build job failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePostInput(input PostInput, creating bool) error {
|
|
if strings.TrimSpace(input.Title) == "" {
|
|
return errors.New("title is required")
|
|
}
|
|
if strings.TrimSpace(input.Slug) == "" {
|
|
return errors.New("slug is required")
|
|
}
|
|
if input.Status == "" {
|
|
if creating {
|
|
input.Status = PostStatusDraft
|
|
} else {
|
|
return errors.New("status is required")
|
|
}
|
|
}
|
|
if !ValidPostStatus(input.Status) {
|
|
return fmt.Errorf("invalid post status: %s", input.Status)
|
|
}
|
|
if creating && input.Status == PostStatusDeleted {
|
|
return errors.New("new post cannot be deleted")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) attachTags(ctx context.Context, posts []Post) error {
|
|
if len(posts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := make([]string, 0, len(posts))
|
|
index := make(map[string]int, len(posts))
|
|
for i := range posts {
|
|
ids = append(ids, posts[i].ID)
|
|
index[posts[i].ID] = i
|
|
posts[i].Tags = []string{}
|
|
}
|
|
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT pt.post_id::text, t.name
|
|
FROM post_tags pt
|
|
JOIN tags t ON t.id = pt.tag_id
|
|
WHERE pt.post_id::text = ANY($1)
|
|
ORDER BY t.name`, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("load post tags: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var postID string
|
|
var tag string
|
|
if err := rows.Scan(&postID, &tag); err != nil {
|
|
return fmt.Errorf("scan post tag: %w", err)
|
|
}
|
|
if i, ok := index[postID]; ok {
|
|
posts[i].Tags = append(posts[i].Tags, tag)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("post tags rows: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertPost(ctx context.Context, tx pgx.Tx, input PostInput) (Post, error) {
|
|
if input.Status == "" {
|
|
input.Status = PostStatusDraft
|
|
}
|
|
if input.SlugSource == "" {
|
|
input.SlugSource = "manual"
|
|
}
|
|
|
|
post, err := scanPost(tx.QueryRow(ctx, `
|
|
INSERT INTO posts (slug, title, summary, body_markdown, status, cover, slug_source, slug_locked, published_at, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $5 IN ('published', 'archived') THEN now() ELSE NULL END, COALESCE($9, now()))
|
|
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
|
input.Slug,
|
|
input.Title,
|
|
input.Summary,
|
|
input.BodyMarkdown,
|
|
input.Status,
|
|
input.Cover,
|
|
input.SlugSource,
|
|
input.SlugLocked,
|
|
input.CreatedAt,
|
|
))
|
|
if err != nil {
|
|
return Post{}, normalizeStoreError("insert post", err)
|
|
}
|
|
return post, nil
|
|
}
|
|
|
|
func updatePost(ctx context.Context, tx pgx.Tx, id string, input PostInput) (Post, error) {
|
|
if input.SlugSource == "" {
|
|
input.SlugSource = "manual"
|
|
}
|
|
|
|
post, err := scanPost(tx.QueryRow(ctx, `
|
|
UPDATE posts
|
|
SET slug = $2,
|
|
title = $3,
|
|
summary = $4,
|
|
body_markdown = $5,
|
|
status = $6,
|
|
cover = $7,
|
|
slug_source = $8,
|
|
slug_locked = $9,
|
|
version = version + 1,
|
|
published_at = CASE WHEN $6 IN ('published', 'archived') AND published_at IS NULL THEN now() ELSE published_at END,
|
|
created_at = COALESCE($10, created_at),
|
|
updated_at = now()
|
|
WHERE id = $1 AND deleted_at IS NULL
|
|
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
|
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
|
id,
|
|
input.Slug,
|
|
input.Title,
|
|
input.Summary,
|
|
input.BodyMarkdown,
|
|
input.Status,
|
|
input.Cover,
|
|
input.SlugSource,
|
|
input.SlugLocked,
|
|
input.CreatedAt,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Post{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Post{}, normalizeStoreError("update post", err)
|
|
}
|
|
return post, nil
|
|
}
|
|
|
|
func insertPostVersion(ctx context.Context, tx pgx.Tx, post Post, reason VersionReason) error {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
post.ID,
|
|
post.Version,
|
|
post.Title,
|
|
post.Summary,
|
|
post.BodyMarkdown,
|
|
post.Status,
|
|
reason,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert post version: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func replacePostTags(ctx context.Context, tx pgx.Tx, postID string, tags []string) error {
|
|
if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID); err != nil {
|
|
return fmt.Errorf("clear post tags: %w", err)
|
|
}
|
|
|
|
for _, name := range normalizeTagNames(tags) {
|
|
slug := tagSlug(name)
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
|
|
var tagID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO tags (name, slug)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (slug)
|
|
DO UPDATE SET name = excluded.name, updated_at = now()
|
|
RETURNING id`, name, slug).Scan(&tagID); err != nil {
|
|
return fmt.Errorf("upsert tag %s: %w", name, err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO post_tags (post_id, tag_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING`, postID, tagID); err != nil {
|
|
return fmt.Errorf("link post tag %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeTagNames(tags []string) []string {
|
|
seen := map[string]bool{}
|
|
var normalized []string
|
|
for _, tag := range tags {
|
|
name := strings.TrimSpace(tag)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(name)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
normalized = append(normalized, name)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func tagSlug(tag string) string {
|
|
slug := strings.ToLower(strings.TrimSpace(tag))
|
|
slug = strings.ReplaceAll(slug, "_", "-")
|
|
slug = strings.Join(strings.Fields(slug), "-")
|
|
return strings.Trim(slug, "-")
|
|
}
|
|
|
|
func sanitizeSlug(slug string) string {
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
replacer := strings.NewReplacer("_", "-", " ", "-")
|
|
slug = replacer.Replace(slug)
|
|
var builder strings.Builder
|
|
lastHyphen := false
|
|
for _, r := range slug {
|
|
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
|
if valid {
|
|
builder.WriteRune(r)
|
|
lastHyphen = false
|
|
continue
|
|
}
|
|
if r == '-' && !lastHyphen {
|
|
builder.WriteRune('-')
|
|
lastHyphen = true
|
|
}
|
|
}
|
|
cleaned := strings.Trim(builder.String(), "-")
|
|
if len(cleaned) > 80 {
|
|
cleaned = strings.Trim(cleaned[:80], "-")
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func insertBuildJob(ctx context.Context, tx pgx.Tx, trigger BuildJobTrigger, postID *string) (BuildJob, error) {
|
|
job, err := scanBuildJob(tx.QueryRow(ctx, `
|
|
INSERT INTO build_jobs (trigger, status, post_id)
|
|
VALUES ($1, 'queued', $2)
|
|
RETURNING id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by`,
|
|
trigger,
|
|
postID,
|
|
))
|
|
if err != nil {
|
|
return BuildJob{}, fmt.Errorf("insert build job: %w", err)
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
type postScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanPost(row postScanner) (Post, error) {
|
|
var post Post
|
|
var publishedAt sql.NullTime
|
|
var deletedAt sql.NullTime
|
|
err := row.Scan(
|
|
&post.ID,
|
|
&post.Slug,
|
|
&post.Title,
|
|
&post.Summary,
|
|
&post.BodyMarkdown,
|
|
&post.Status,
|
|
&post.Cover,
|
|
&post.Version,
|
|
&post.SlugSource,
|
|
&post.SlugLocked,
|
|
&publishedAt,
|
|
&post.CreatedAt,
|
|
&post.UpdatedAt,
|
|
&deletedAt,
|
|
)
|
|
if err != nil {
|
|
return Post{}, err
|
|
}
|
|
post.PublishedAt = nullTimePtr(publishedAt)
|
|
post.DeletedAt = nullTimePtr(deletedAt)
|
|
return post, nil
|
|
}
|
|
|
|
func scanBuildJob(row postScanner) (BuildJob, error) {
|
|
var job BuildJob
|
|
var postID sql.NullString
|
|
var startedAt sql.NullTime
|
|
var finishedAt sql.NullTime
|
|
var createdBy sql.NullString
|
|
err := row.Scan(
|
|
&job.ID,
|
|
&job.Trigger,
|
|
&job.Status,
|
|
&postID,
|
|
&startedAt,
|
|
&finishedAt,
|
|
&job.Log,
|
|
&job.Error,
|
|
&job.CreatedAt,
|
|
&createdBy,
|
|
)
|
|
if err != nil {
|
|
return BuildJob{}, err
|
|
}
|
|
job.PostID = nullStringPtr(postID)
|
|
job.StartedAt = nullTimePtr(startedAt)
|
|
job.FinishedAt = nullTimePtr(finishedAt)
|
|
job.CreatedBy = nullStringPtr(createdBy)
|
|
return job, nil
|
|
}
|
|
|
|
func scanAsset(row postScanner) (Asset, error) {
|
|
var asset Asset
|
|
var createdBy sql.NullString
|
|
err := row.Scan(
|
|
&asset.ID,
|
|
&asset.Path,
|
|
&asset.OriginalName,
|
|
&asset.MimeType,
|
|
&asset.SizeBytes,
|
|
&asset.SHA256,
|
|
&asset.CreatedAt,
|
|
&createdBy,
|
|
)
|
|
if err != nil {
|
|
return Asset{}, err
|
|
}
|
|
asset.CreatedBy = nullStringPtr(createdBy)
|
|
return asset, nil
|
|
}
|
|
|
|
func nullTimePtr(value sql.NullTime) *time.Time {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
return &value.Time
|
|
}
|
|
|
|
func nullStringPtr(value sql.NullString) *string {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
return &value.String
|
|
}
|
|
|
|
func normalizeStoreError(action string, err error) error {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return fmt.Errorf("%s: slug already exists", action)
|
|
}
|
|
return fmt.Errorf("%s: %w", action, err)
|
|
}
|