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) }