feat: add admin publishing workflow and yar theme
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.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
823
backend/internal/admin/store.go
Normal file
823
backend/internal/admin/store.go
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue