osaet/backend/internal/admin/store.go
yarnom f0b50d13ea 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.
2026-06-01 15:48:04 +08:00

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(&currentStatus)
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(&currentStatus)
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)
}