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