feat(store): NewStore, CreatePost, GetPost, ListPosts, AddComment, ListComments

This commit is contained in:
Hermes 2026-06-22 22:34:37 -07:00
parent c58e9f53ec
commit a12b4da978

269
api/store.go Normal file
View file

@ -0,0 +1,269 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// Store — the persistence layer. Postgres-only.
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// newID — 8 bytes crypto/rand → 16 hex chars. Cheap and unique enough.
func newID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Now — central time helper, UTC.
func Now() time.Time { return time.Now().UTC() }
// ---------- Authors ----------
func (s *Store) GetAuthor(id string) (*Author, error) {
row := s.db.QueryRow(
`SELECT id, kind, display_name, COALESCE(avatar_url,''), created_at, updated_at
FROM authors WHERE id=$1`, id)
var a Author
if err := row.Scan(&a.ID, &a.Kind, &a.DisplayName, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) UpsertAuthor(a *Author) error {
_, err := s.db.Exec(
`INSERT INTO authors (id, kind, display_name, avatar_url, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (id) DO UPDATE
SET kind=EXCLUDED.kind,
display_name=EXCLUDED.display_name,
avatar_url=EXCLUDED.avatar_url,
updated_at=NOW()`,
a.ID, a.Kind, a.DisplayName, a.AvatarURL, Now(), Now())
return err
}
// ---------- Channels ----------
func (s *Store) ListChannels() ([]Channel, error) {
rows, err := s.db.Query(
`SELECT slug, name, description, created_at FROM channels ORDER BY slug`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Channel
for rows.Next() {
var c Channel
if err := rows.Scan(&c.Slug, &c.Name, &c.Description, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) UpsertChannel(c *Channel) error {
_, err := s.db.Exec(
`INSERT INTO channels (slug, name, description, created_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, description=EXCLUDED.description`,
c.Slug, c.Name, c.Description, Now())
return err
}
// ---------- Posts ----------
// CreatePost — inserts a post and links it to channels atomically.
func (s *Store) CreatePost(authorID, title, body string, channels []string) (*Post, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck
p := &Post{
ID: newID(),
AuthorID: authorID,
Title: title,
Body: body,
BumpedAt: Now(),
CreatedAt: Now(),
UpdatedAt: Now(),
Channels: channels,
}
_, err = tx.Exec(
`INSERT INTO posts (id, author_id, title, body, bumped_at, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
p.ID, p.AuthorID, p.Title, p.Body, p.BumpedAt, p.CreatedAt, p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert post: %w", err)
}
for _, ch := range channels {
_, err = tx.Exec(
`INSERT INTO post_channels (post_id, channel, added_by) VALUES ($1,$2,$3)
ON CONFLICT DO NOTHING`,
p.ID, ch, authorID)
if err != nil {
return nil, fmt.Errorf("link channel %s: %w", ch, err)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return p, nil
}
// GetPost — fetch one post and all its channels.
func (s *Store) GetPost(id string) (*Post, error) {
row := s.db.QueryRow(
`SELECT id, author_id, title, body, bumped_at, created_at, updated_at, is_deleted, COALESCE(parent_post,'')
FROM posts WHERE id=$1`, id)
var p Post
if err := row.Scan(&p.ID, &p.AuthorID, &p.Title, &p.Body, &p.BumpedAt, &p.CreatedAt, &p.UpdatedAt, &p.IsDeleted, &p.ParentPost); err != nil {
return nil, err
}
rows, err := s.db.Query(`SELECT channel FROM post_channels WHERE post_id=$1 ORDER BY channel`, p.ID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var ch string
if err := rows.Scan(&ch); err != nil {
return nil, err
}
p.Channels = append(p.Channels, ch)
}
return &p, rows.Err()
}
// ListPosts — newest-bumped first, optional channel filter.
func (s *Store) ListPosts(channel string, limit int) ([]Post, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var (
rows *sql.Rows
err error
)
if channel == "" {
rows, err = s.db.Query(
`SELECT id, author_id, title, body, bumped_at, created_at, updated_at, is_deleted, COALESCE(parent_post,'')
FROM posts WHERE NOT is_deleted
ORDER BY bumped_at DESC LIMIT $1`, limit)
} else {
rows, err = s.db.Query(
`SELECT p.id, p.author_id, p.title, p.body, p.bumped_at, p.created_at, p.updated_at, p.is_deleted, COALESCE(p.parent_post,'')
FROM posts p
JOIN post_channels pc ON pc.post_id = p.id
WHERE NOT p.is_deleted AND pc.channel = $1
ORDER BY p.bumped_at DESC LIMIT $2`, channel, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.AuthorID, &p.Title, &p.Body, &p.BumpedAt, &p.CreatedAt, &p.UpdatedAt, &p.IsDeleted, &p.ParentPost); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ---------- Comments ----------
// AddComment — inserts a comment, computes path, bumps the post.
func (s *Store) AddComment(postID, parentID, authorID, body string) (*Comment, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck
var path string
var depth int
if parentID == "" {
// top-level: depth 0, path is just the new id
depth = 0
} else {
// child: inherit parent path, append new id
var ppath string
var pdepth int
if err := tx.QueryRow(
`SELECT path, depth FROM comments WHERE id=$1 AND post_id=$2`,
parentID, postID).Scan(&ppath, &pdepth); err != nil {
return nil, fmt.Errorf("parent comment not found: %w", err)
}
depth = pdepth + 1
path = ppath
}
id := newID()
if path == "" {
path = id
} else {
path = path + "." + id
}
now := Now()
c := &Comment{
ID: id,
PostID: postID,
ParentID: parentID,
Path: path,
Depth: depth,
AuthorID: authorID,
Body: body,
CreatedAt: now,
UpdatedAt: now,
}
_, err = tx.Exec(
`INSERT INTO comments (id, post_id, parent_id, path, depth, author_id, body, created_at, updated_at)
VALUES ($1,$2,NULLIF($3,''),$4,$5,$6,$7,$8,$9)`,
c.ID, c.PostID, c.ParentID, c.Path, c.Depth, c.AuthorID, c.Body, c.CreatedAt, c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert comment: %w", err)
}
// Bump the post — the bump.
if _, err := tx.Exec(
`UPDATE posts SET bumped_at=$1 WHERE id=$2`, now, postID); err != nil {
return nil, fmt.Errorf("bump post: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return c, nil
}
// ListComments — all comments for a post, ordered by path.
func (s *Store) ListComments(postID string) ([]Comment, error) {
rows, err := s.db.Query(
`SELECT id, post_id, COALESCE(parent_id,''), path, depth, author_id, body, created_at, updated_at, is_deleted
FROM comments WHERE post_id=$1 ORDER BY path`, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Comment
for rows.Next() {
var c Comment
if err := rows.Scan(&c.ID, &c.PostID, &c.ParentID, &c.Path, &c.Depth, &c.AuthorID, &c.Body, &c.CreatedAt, &c.UpdatedAt, &c.IsDeleted); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}