From a12b4da9785533a138f48d507f20ee07c3a1071e Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 22 Jun 2026 22:34:37 -0700 Subject: [PATCH] feat(store): NewStore, CreatePost, GetPost, ListPosts, AddComment, ListComments --- api/store.go | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 api/store.go diff --git a/api/store.go b/api/store.go new file mode 100644 index 0000000..7000048 --- /dev/null +++ b/api/store.go @@ -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() +}