feat(store): NewStore, CreatePost, GetPost, ListPosts, AddComment, ListComments
This commit is contained in:
parent
c58e9f53ec
commit
a12b4da978
1 changed files with 269 additions and 0 deletions
269
api/store.go
Normal file
269
api/store.go
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue