aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/blogs.go118
-rw-r--r--internal/store/images.go25
-rw-r--r--internal/store/menu.go54
-rw-r--r--internal/store/modules.go57
-rw-r--r--internal/store/pages.go69
-rw-r--r--internal/store/posts.go58
-rw-r--r--internal/store/sections.go51
-rw-r--r--internal/store/store.go39
-rw-r--r--internal/store/users.go22
9 files changed, 299 insertions, 194 deletions
diff --git a/internal/store/blogs.go b/internal/store/blogs.go
index b7edee0..a2e88a8 100644
--- a/internal/store/blogs.go
+++ b/internal/store/blogs.go
@@ -3,34 +3,52 @@ package store
import (
"context"
"encoding/json"
+ "errors"
+ "fmt"
"time"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
)
+// Blog is a registry row (control database) plus the settings row of the
+// blog's own database, which Store.Open fills in.
type Blog struct {
ID int64
OwnerID int64
Subdomain string
+ DBName string
+ CreatedAt time.Time
+ // from the blog database
Title string
Tagline string
ThemeJSON json.RawMessage
- CreatedAt time.Time
UpdatedAt time.Time
}
-const blogCols = `id, owner_id, subdomain, title, tagline, theme, created_at, updated_at`
+const blogCols = `id, owner_id, subdomain, db_name, created_at`
func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) {
var b Blog
- err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.Title, &b.Tagline, &b.ThemeJSON, &b.CreatedAt, &b.UpdatedAt)
+ err := row.Scan(&b.ID, &b.OwnerID, &b.Subdomain, &b.DBName, &b.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &b, nil
}
-// CreateBlogger creates a user, their blog and a default home page in one transaction.
+func (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error {
+ err := bs.db.QueryRow(ctx, `SELECT title, tagline, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.ThemeJSON, &b.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404
+ return fmt.Errorf("database %s of blog %q has no settings row (empty or wrong dump?)", b.DBName, b.Subdomain)
+ }
+ if err != nil {
+ return fmt.Errorf("settings of %s: %w", b.DBName, err)
+ }
+ return nil
+}
+
+// CreateBlogger creates a user, their blog and a default home page.
func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdomain, title string) (*User, *Blog, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
@@ -43,13 +61,10 @@ func (s *Store) CreateBlogger(ctx context.Context, username, passwordHash, subdo
if err != nil {
return nil, nil, err
}
- b, err := createBlog(ctx, tx, u.ID, subdomain, title)
+ b, err := s.createBlog(ctx, tx, u.ID, subdomain, title)
if err != nil {
return nil, nil, err
}
- if err := tx.Commit(ctx); err != nil {
- return nil, nil, err
- }
return u, b, nil
}
@@ -60,30 +75,64 @@ func (s *Store) CreateBlog(ctx context.Context, ownerID int64, subdomain, title
return nil, err
}
defer tx.Rollback(ctx)
- b, err := createBlog(ctx, tx, ownerID, subdomain, title)
+ return s.createBlog(ctx, tx, ownerID, subdomain, title)
+}
+
+// createBlog registers the blog in tx, then creates and fills its database and
+// commits. The registry insert goes first so a taken subdomain fails before
+// any DDL; if anything after that fails the transaction rolls back and the new
+// database is dropped again.
+func (s *Store) createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
+ name := db.DBName(subdomain)
+ b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, db_name) VALUES ($1,$2,$3) RETURNING `+blogCols,
+ ownerID, subdomain, name))
+ if err != nil {
+ return nil, err
+ }
+ if err := s.cluster.CreateBlogDB(ctx, name); err != nil {
+ return nil, err
+ }
+ bs, err := s.fillNewBlog(ctx, b, title)
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
if err != nil {
+ s.cluster.DropBlogDB(ctx, name)
return nil, err
}
- return b, tx.Commit(ctx)
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ return b, nil
}
-func createBlog(ctx context.Context, tx pgx.Tx, ownerID int64, subdomain, title string) (*Blog, error) {
- b, err := scanBlog(tx.QueryRow(ctx, `INSERT INTO blogs (owner_id, subdomain, title) VALUES ($1,$2,$3) RETURNING `+blogCols,
- ownerID, subdomain, title))
+// fillNewBlog writes what every new blog starts with: its settings, a home
+// page in the menu and the default layout.
+func (s *Store) fillNewBlog(ctx context.Context, b *Blog, title string) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
if err != nil {
return nil, err
}
+ bs := &BlogStore{db: pool}
+ btx, err := pool.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer btx.Rollback(ctx)
+ if _, err := btx.Exec(ctx, `INSERT INTO settings (title, created_at, updated_at) VALUES ($1,$2,$2)`, title, b.CreatedAt); err != nil {
+ return nil, err
+ }
var homeID int64
- if err := tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, nav_order, is_home) VALUES ($1,'home','Home',0,true) RETURNING id`, b.ID).Scan(&homeID); err != nil {
+ if err := btx.QueryRow(ctx, `INSERT INTO pages (slug, title, nav_order, is_home) VALUES ('home','Home',0,true) RETURNING id`).Scan(&homeID); err != nil {
return nil, wrap(err)
}
- if err := addMenuPage(ctx, tx, b.ID, homeID); err != nil {
+ if err := addMenuPage(ctx, btx, homeID); err != nil {
return nil, err
}
- if err := insertDefaultModules(ctx, tx, b.ID); err != nil {
+ if err := insertDefaultModules(ctx, btx); err != nil {
return nil, err
}
- return b, nil
+ return bs, btx.Commit(ctx)
}
func (s *Store) BlogByID(ctx context.Context, id int64) (*Blog, error) {
@@ -98,17 +147,38 @@ func (s *Store) BlogByOwner(ctx context.Context, ownerID int64) (*Blog, error) {
return scanBlog(s.db.QueryRow(ctx, `SELECT `+blogCols+` FROM blogs WHERE owner_id=$1`, ownerID))
}
-func (s *Store) UpdateBlogSettings(ctx context.Context, id int64, title, tagline string) error {
- _, err := s.db.Exec(ctx, `UPDATE blogs SET title=$2, tagline=$3, updated_at=now() WHERE id=$1`, id, title, tagline)
- return err
+// ListBlogs returns every registered blog, oldest first.
+func (s *Store) ListBlogs(ctx context.Context) ([]Blog, error) {
+ rows, err := s.db.Query(ctx, `SELECT `+blogCols+` FROM blogs ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Blog
+ for rows.Next() {
+ b, err := scanBlog(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *b)
+ }
+ return out, rows.Err()
+}
+
+// DeleteBlog unregisters the blog and drops its database.
+func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error {
+ if _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, b.ID); err != nil {
+ return err
+ }
+ return s.cluster.DropBlogDB(ctx, b.DBName)
}
-func (s *Store) UpdateBlogTheme(ctx context.Context, id int64, theme json.RawMessage) error {
- _, err := s.db.Exec(ctx, `UPDATE blogs SET theme=$2, updated_at=now() WHERE id=$1`, id, theme)
+func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline string) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, updated_at=now()`, title, tagline)
return err
}
-func (s *Store) DeleteBlog(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM blogs WHERE id=$1`, id)
+func (bs *BlogStore) UpdateTheme(ctx context.Context, theme json.RawMessage) error {
+ _, err := bs.db.Exec(ctx, `UPDATE settings SET theme=$1, updated_at=now()`, theme)
return err
}
diff --git a/internal/store/images.go b/internal/store/images.go
index 3699e75..b222468 100644
--- a/internal/store/images.go
+++ b/internal/store/images.go
@@ -9,7 +9,6 @@ import (
type Image struct {
ID uuid.UUID
- BlogID int64
Filename string
ContentType string
Size int
@@ -17,15 +16,15 @@ type Image struct {
CreatedAt time.Time
}
-func (s *Store) CreateImage(ctx context.Context, blogID int64, filename, contentType string, data []byte) (*Image, error) {
- img := &Image{ID: uuid.New(), BlogID: blogID, Filename: filename, ContentType: contentType, Size: len(data)}
- err := s.db.QueryRow(ctx, `INSERT INTO images (id, blog_id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5,$6) RETURNING created_at`,
- img.ID, blogID, filename, contentType, len(data), data).Scan(&img.CreatedAt)
+func (bs *BlogStore) CreateImage(ctx context.Context, filename, contentType string, data []byte) (*Image, error) {
+ img := &Image{ID: uuid.New(), Filename: filename, ContentType: contentType, Size: len(data)}
+ err := bs.db.QueryRow(ctx, `INSERT INTO images (id, filename, content_type, size, data) VALUES ($1,$2,$3,$4,$5) RETURNING created_at`,
+ img.ID, filename, contentType, len(data), data).Scan(&img.CreatedAt)
return img, err
}
-func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
- rows, err := s.db.Query(ctx, `SELECT id, blog_id, filename, content_type, size, created_at FROM images WHERE blog_id=$1 ORDER BY created_at DESC`, blogID)
+func (bs *BlogStore) ListImages(ctx context.Context) ([]Image, error) {
+ rows, err := bs.db.Query(ctx, `SELECT id, filename, content_type, size, created_at FROM images ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
@@ -33,7 +32,7 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
var out []Image
for rows.Next() {
var i Image
- if err := rows.Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
+ if err := rows.Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.CreatedAt); err != nil {
return nil, err
}
out = append(out, i)
@@ -42,17 +41,17 @@ func (s *Store) ListImages(ctx context.Context, blogID int64) ([]Image, error) {
}
// ImageData loads an image including its bytes.
-func (s *Store) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
+func (bs *BlogStore) ImageData(ctx context.Context, id uuid.UUID) (*Image, error) {
var i Image
- err := s.db.QueryRow(ctx, `SELECT id, blog_id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
- Scan(&i.ID, &i.BlogID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt)
+ err := bs.db.QueryRow(ctx, `SELECT id, filename, content_type, size, data, created_at FROM images WHERE id=$1`, id).
+ Scan(&i.ID, &i.Filename, &i.ContentType, &i.Size, &i.Data, &i.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &i, nil
}
-func (s *Store) DeleteImage(ctx context.Context, blogID int64, id uuid.UUID) error {
- _, err := s.db.Exec(ctx, `DELETE FROM images WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteImage(ctx context.Context, id uuid.UUID) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM images WHERE id=$1`, id)
return err
}
diff --git a/internal/store/menu.go b/internal/store/menu.go
index 2d3eabd..cedb7ad 100644
--- a/internal/store/menu.go
+++ b/internal/store/menu.go
@@ -2,14 +2,11 @@ package store
import (
"context"
-
- "github.com/jackc/pgx/v5/pgconn"
)
// MenuItem is one entry of a blog's menu: either a page of the blog or a custom link.
type MenuItem struct {
ID int64
- BlogID int64
PageID *int64 // nil for custom links
Label string // custom links only; pages use their title
URL string // custom links only
@@ -39,11 +36,11 @@ func (m MenuItem) Href() string {
return "/" + m.PageSlug
}
-const menuCols = `m.id, m.blog_id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
+const menuCols = `m.id, m.page_id, m.label, m.url, m.sort_order, coalesce(p.title,''), coalesce(p.slug,''), coalesce(p.is_home,false)`
-func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error) {
- rows, err := s.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
- WHERE m.blog_id=$1 ORDER BY m.sort_order, m.id`, blogID)
+func (bs *BlogStore) ListMenu(ctx context.Context) ([]MenuItem, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+menuCols+` FROM menu_items m LEFT JOIN pages p ON p.id=m.page_id
+ ORDER BY m.sort_order, m.id`)
if err != nil {
return nil, err
}
@@ -51,7 +48,7 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
var out []MenuItem
for rows.Next() {
var m MenuItem
- if err := rows.Scan(&m.ID, &m.BlogID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
+ if err := rows.Scan(&m.ID, &m.PageID, &m.Label, &m.URL, &m.SortOrder, &m.PageTitle, &m.PageSlug, &m.IsHome); err != nil {
return nil, err
}
out = append(out, m)
@@ -60,41 +57,41 @@ func (s *Store) ListMenu(ctx context.Context, blogID int64) ([]MenuItem, error)
}
// AddMenuPage appends a page to the menu; a page already in the menu is left where it is.
-func (s *Store) AddMenuPage(ctx context.Context, blogID, pageID int64) error {
- return addMenuPage(ctx, s.db, blogID, pageID)
+func (bs *BlogStore) AddMenuPage(ctx context.Context, pageID int64) error {
+ return addMenuPage(ctx, bs.db, pageID)
}
-func addMenuPage(ctx context.Context, db querier, blogID, pageID int64) error {
- _, err := db.Exec(ctx, `INSERT INTO menu_items (blog_id, page_id, sort_order)
- SELECT $1, id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1)
- FROM pages WHERE blog_id=$1 AND id=$2 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, blogID, pageID)
+func addMenuPage(ctx context.Context, db querier, pageID int64) error {
+ _, err := db.Exec(ctx, `INSERT INTO menu_items (page_id, sort_order)
+ SELECT id, (SELECT coalesce(max(sort_order),-1)+1 FROM menu_items)
+ FROM pages WHERE id=$1 ON CONFLICT (page_id) WHERE page_id IS NOT NULL DO NOTHING`, pageID)
return err
}
// SetPageInMenu adds or removes a page's menu entry.
-func (s *Store) SetPageInMenu(ctx context.Context, blogID, pageID int64, on bool) error {
+func (bs *BlogStore) SetPageInMenu(ctx context.Context, pageID int64, on bool) error {
if on {
- return s.AddMenuPage(ctx, blogID, pageID)
+ return bs.AddMenuPage(ctx, pageID)
}
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, blogID, pageID)
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, pageID)
return err
}
// AddMenuLink appends a custom link to the menu.
-func (s *Store) AddMenuLink(ctx context.Context, blogID int64, label, url string) error {
- _, err := s.db.Exec(ctx, `INSERT INTO menu_items (blog_id, label, url, sort_order)
- VALUES ($1,$2,$3,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items WHERE blog_id=$1))`, blogID, label, url)
+func (bs *BlogStore) AddMenuLink(ctx context.Context, label, url string) error {
+ _, err := bs.db.Exec(ctx, `INSERT INTO menu_items (label, url, sort_order)
+ VALUES ($1,$2,(SELECT coalesce(max(sort_order),-1)+1 FROM menu_items))`, label, url)
return err
}
-func (s *Store) DeleteMenuItem(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteMenuItem(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM menu_items WHERE id=$1`, id)
return err
}
// MoveMenuItem swaps sort_order with the neighbouring entry (dir = -1 up, +1 down).
-func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) error {
- items, err := s.ListMenu(ctx, blogID)
+func (bs *BlogStore) MoveMenuItem(ctx context.Context, id int64, dir int) error {
+ items, err := bs.ListMenu(ctx)
if err != nil {
return err
}
@@ -109,20 +106,15 @@ func (s *Store) MoveMenuItem(ctx context.Context, blogID, id int64, dir int) err
return nil
}
items[idx], items[j] = items[j], items[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range items { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE menu_items SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
return tx.Commit(ctx)
}
-
-// querier is what the helpers shared with page transactions need: the pool or a pgx.Tx.
-type querier interface {
- Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
-}
diff --git a/internal/store/modules.go b/internal/store/modules.go
index c7d9760..e0c3cd5 100644
--- a/internal/store/modules.go
+++ b/internal/store/modules.go
@@ -9,7 +9,6 @@ import (
// block of custom HTML… placed in one of the areas of the page.
type Module struct {
ID int64
- BlogID int64
Area string // header | left | right | above | below | footer
Kind string // title | logo | menu | archive | recent | html | rss | text | sitemap
Title string // optional heading (side columns and footer)
@@ -20,20 +19,20 @@ type Module struct {
UpdatedAt time.Time
}
-const moduleCols = `id, blog_id, area, kind, title, body, count, sort_order, created_at, updated_at`
+const moduleCols = `id, area, kind, title, body, count, sort_order, created_at, updated_at`
func scanModule(row interface{ Scan(...any) error }) (*Module, error) {
var m Module
- err := row.Scan(&m.ID, &m.BlogID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
+ err := row.Scan(&m.ID, &m.Area, &m.Kind, &m.Title, &m.Body, &m.Count, &m.SortOrder, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &m, nil
}
-// ListModules returns every module of a blog, grouped by area in display order.
-func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error) {
- rows, err := s.db.Query(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 ORDER BY area, sort_order, id`, blogID)
+// ListModules returns every module of the blog, grouped by area in display order.
+func (bs *BlogStore) ListModules(ctx context.Context) ([]Module, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+moduleCols+` FROM modules ORDER BY area, sort_order, id`)
if err != nil {
return nil, err
}
@@ -49,36 +48,36 @@ func (s *Store) ListModules(ctx context.Context, blogID int64) ([]Module, error)
return out, rows.Err()
}
-func (s *Store) ModuleByID(ctx context.Context, blogID, id int64) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) ModuleByID(ctx context.Context, id int64) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `SELECT `+moduleCols+` FROM modules WHERE id=$1`, id))
}
// CreateModule appends a module to the end of its area.
-func (s *Store) CreateModule(ctx context.Context, m *Module) (*Module, error) {
- return scanModule(s.db.QueryRow(ctx, `INSERT INTO modules (blog_id, area, kind, title, body, count, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE blog_id=$1 AND area=$2)) RETURNING `+moduleCols,
- m.BlogID, m.Area, m.Kind, m.Title, m.Body, m.Count))
+func (bs *BlogStore) CreateModule(ctx context.Context, m *Module) (*Module, error) {
+ return scanModule(bs.db.QueryRow(ctx, `INSERT INTO modules (area, kind, title, body, count, sort_order)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(sort_order),-1)+1 FROM modules WHERE area=$1)) RETURNING `+moduleCols,
+ m.Area, m.Kind, m.Title, m.Body, m.Count))
}
// UpdateModule saves the editable settings; area and kind never change.
-func (s *Store) UpdateModule(ctx context.Context, m *Module) error {
- _, err := s.db.Exec(ctx, `UPDATE modules SET title=$3, body=$4, count=$5, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- m.BlogID, m.ID, m.Title, m.Body, m.Count)
+func (bs *BlogStore) UpdateModule(ctx context.Context, m *Module) error {
+ _, err := bs.db.Exec(ctx, `UPDATE modules SET title=$2, body=$3, count=$4, updated_at=now() WHERE id=$1`,
+ m.ID, m.Title, m.Body, m.Count)
return wrap(err)
}
-func (s *Store) DeleteModule(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteModule(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM modules WHERE id=$1`, id)
return err
}
// MoveModule swaps sort_order with the neighbouring module of the same area (dir = -1 up, +1 down).
-func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error {
- cur, err := s.ModuleByID(ctx, blogID, id)
+func (bs *BlogStore) MoveModule(ctx context.Context, id int64, dir int) error {
+ cur, err := bs.ModuleByID(ctx, id)
if err != nil {
return err
}
- all, err := s.ListModules(ctx, blogID)
+ all, err := bs.ListModules(ctx)
if err != nil {
return err
}
@@ -98,13 +97,13 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
return nil
}
mods[idx], mods[j] = mods[j], mods[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, m := range mods { // renumber the area; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, m.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE modules SET sort_order=$2 WHERE id=$1`, m.ID, i); err != nil {
return err
}
}
@@ -112,16 +111,16 @@ func (s *Store) MoveModule(ctx context.Context, blogID, id int64, dir int) error
}
// ResetModules puts the layout back to what a new blog starts with.
-func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) ResetModules(ctx context.Context) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `DELETE FROM modules WHERE blog_id=$1`, blogID); err != nil {
+ if _, err := tx.Exec(ctx, `DELETE FROM modules`); err != nil {
return err
}
- if err := insertDefaultModules(ctx, tx, blogID); err != nil {
+ if err := insertDefaultModules(ctx, tx); err != nil {
return err
}
return tx.Commit(ctx)
@@ -129,8 +128,8 @@ func (s *Store) ResetModules(ctx context.Context, blogID int64) error {
// insertDefaultModules is the starting layout: title and menu in the header,
// an RSS link in the footer. Must match what the layout migration gave old blogs.
-func insertDefaultModules(ctx context.Context, tx querier, blogID int64) error {
- _, err := tx.Exec(ctx, `INSERT INTO modules (blog_id, area, kind, sort_order) VALUES
- ($1,'header','title',0), ($1,'header','menu',1), ($1,'footer','rss',0)`, blogID)
+func insertDefaultModules(ctx context.Context, tx querier) error {
+ _, err := tx.Exec(ctx, `INSERT INTO modules (area, kind, sort_order) VALUES
+ ('header','title',0), ('header','menu',1), ('footer','rss',0)`)
return err
}
diff --git a/internal/store/pages.go b/internal/store/pages.go
index cd0e652..55cad33 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -7,7 +7,6 @@ import (
type Page struct {
ID int64
- BlogID int64
Slug string
Title string
IntroMD string
@@ -19,20 +18,20 @@ type Page struct {
PostCount int // filled by ListPages only
}
-const pageCols = `id, blog_id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
+const pageCols = `id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
- err := row.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
+ err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
if err != nil {
return nil, wrap(err)
}
return &p, nil
}
-func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
- rows, err := s.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
- FROM pages WHERE blog_id=$1 ORDER BY nav_order, id`, blogID)
+func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+pageCols+`, (SELECT count(*) FROM posts WHERE page_id=pages.id)
+ FROM pages ORDER BY nav_order, id`)
if err != nil {
return nil, err
}
@@ -40,7 +39,7 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
var out []Page
for rows.Next() {
var p Page
- if err := rows.Scan(&p.ID, &p.BlogID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
return nil, err
}
out = append(out, p)
@@ -48,34 +47,34 @@ func (s *Store) ListPages(ctx context.Context, blogID int64) ([]Page, error) {
return out, rows.Err()
}
-func (s *Store) PageByID(ctx context.Context, blogID, id int64) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) PageByID(ctx context.Context, id int64) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE id=$1`, id))
}
-func (s *Store) PageBySlug(ctx context.Context, blogID int64, slug string) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 AND slug=$2`, blogID, slug))
+func (bs *BlogStore) PageBySlug(ctx context.Context, slug string) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE slug=$1`, slug))
}
-func (s *Store) HomePage(ctx context.Context, blogID int64) (*Page, error) {
- return scanPage(s.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages WHERE blog_id=$1 ORDER BY is_home DESC, nav_order, id LIMIT 1`, blogID))
+func (bs *BlogStore) HomePage(ctx context.Context) (*Page, error) {
+ return scanPage(bs.db.QueryRow(ctx, `SELECT `+pageCols+` FROM pages ORDER BY is_home DESC, nav_order, id LIMIT 1`))
}
// CreatePage adds a page at the end of the page list and, when ShowInNav is
// set, to the end of the menu.
-func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
- created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (blog_id, slug, title, intro_md, intro_html, nav_order)
- VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages WHERE blog_id=$1)) RETURNING `+pageCols,
- p.BlogID, p.Slug, p.Title, p.IntroMD, p.IntroHTML))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, nav_order)
+ VALUES ($1,$2,$3,$4,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ p.Slug, p.Title, p.IntroMD, p.IntroHTML))
if err != nil {
return nil, err
}
if p.ShowInNav {
- if err := addMenuPage(ctx, tx, p.BlogID, created.ID); err != nil {
+ if err := addMenuPage(ctx, tx, created.ID); err != nil {
return nil, err
}
created.ShowInNav = true
@@ -84,20 +83,20 @@ func (s *Store) CreatePage(ctx context.Context, p *Page) (*Page, error) {
}
// UpdatePage saves the page and keeps its menu entry in step with ShowInNav.
-func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$3, title=$4, intro_md=$5, intro_html=$6 WHERE blog_id=$1 AND id=$2`,
- p.BlogID, p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5 WHERE id=$1`,
+ p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
return wrap(err)
}
if p.ShowInNav {
- err = addMenuPage(ctx, tx, p.BlogID, p.ID)
+ err = addMenuPage(ctx, tx, p.ID)
} else {
- _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE blog_id=$1 AND page_id=$2`, p.BlogID, p.ID)
+ _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE page_id=$1`, p.ID)
}
if err != nil {
return err
@@ -105,30 +104,30 @@ func (s *Store) UpdatePage(ctx context.Context, p *Page) error {
return tx.Commit(ctx)
}
-func (s *Store) DeletePage(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM pages WHERE blog_id=$1 AND id=$2 AND NOT is_home`, blogID, id)
+func (bs *BlogStore) DeletePage(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM pages WHERE id=$1 AND NOT is_home`, id)
return err
}
// SetHomePage moves the home flag to the given page.
-func (s *Store) SetHomePage(ctx context.Context, blogID, id int64) error {
- tx, err := s.db.Begin(ctx)
+func (bs *BlogStore) SetHomePage(ctx context.Context, id int64) error {
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE blog_id=$1 AND is_home`, blogID); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=false WHERE is_home`); err != nil {
return err
}
- if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE blog_id=$1 AND id=$2`, blogID, id); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET is_home=true WHERE id=$1`, id); err != nil {
return err
}
return tx.Commit(ctx)
}
// MovePage swaps nav_order with the neighbouring page (dir = -1 up, +1 down).
-func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
- pages, err := s.ListPages(ctx, blogID)
+func (bs *BlogStore) MovePage(ctx context.Context, id int64, dir int) error {
+ pages, err := bs.ListPages(ctx)
if err != nil {
return err
}
@@ -143,13 +142,13 @@ func (s *Store) MovePage(ctx context.Context, blogID, id int64, dir int) error {
return nil
}
pages[idx], pages[j] = pages[j], pages[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, p := range pages { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, p.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET nav_order=$2 WHERE id=$1`, p.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/posts.go b/internal/store/posts.go
index 9f3c4f9..94bc856 100644
--- a/internal/store/posts.go
+++ b/internal/store/posts.go
@@ -31,8 +31,8 @@ func scanPost(row interface{ Scan(...any) error }) (*Post, error) {
return &p, nil
}
-func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectPosts(ctx context.Context, q string, args ...any) ([]Post, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -48,21 +48,21 @@ func (s *Store) collectPosts(ctx context.Context, q string, args ...any) ([]Post
return out, rows.Err()
}
-// ListPosts returns all posts of a blog for the dashboard, optionally filtered by page.
-func (s *Store) ListPosts(ctx context.Context, blogID int64, pageID int64) ([]Post, error) {
- return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND ($2=0 OR p.page_id=$2) ORDER BY p.created_at DESC, p.id DESC`, blogID, pageID)
+// ListPosts returns all posts of the blog for the dashboard, optionally filtered by page.
+func (bs *BlogStore) ListPosts(ctx context.Context, pageID int64) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE ($1=0 OR p.page_id=$1) ORDER BY p.created_at DESC, p.id DESC`, pageID)
}
// PublishedPosts returns a page of published posts for the public site.
-func (s *Store) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
- posts, err := s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+func (bs *BlogStore) PublishedPosts(ctx context.Context, pageID int64, limit, offset int) ([]Post, int, error) {
+ posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
WHERE p.page_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pageID, limit, offset)
if err != nil {
return nil, 0, err
}
var total int
- err = s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
+ err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE page_id=$1 AND published`, pageID).Scan(&total)
return posts, total, err
}
@@ -74,10 +74,10 @@ type PostRef struct {
CreatedAt time.Time
}
-// PublishedPostIndex lists every published post of a blog, newest first, without bodies.
-func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef, error) {
- rows, err := s.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC`, blogID)
+// PublishedPostIndex lists every published post of the blog, newest first, without bodies.
+func (bs *BlogStore) PublishedPostIndex(ctx context.Context) ([]PostRef, error) {
+ rows, err := bs.db.Query(ctx, `SELECT p.title, p.slug, g.slug, p.created_at FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC`)
if err != nil {
return nil, err
}
@@ -93,40 +93,38 @@ func (s *Store) PublishedPostIndex(ctx context.Context, blogID int64) ([]PostRef
return out, rows.Err()
}
-// RecentPublishedPosts returns the newest published posts across a whole blog (for feeds).
-func (s *Store) RecentPublishedPosts(ctx context.Context, blogID int64, limit int) ([]Post, error) {
- return s.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2`, blogID, limit)
+// RecentPublishedPosts returns the newest published posts across the whole blog (for feeds).
+func (bs *BlogStore) RecentPublishedPosts(ctx context.Context, limit int) ([]Post, error) {
+ return bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+ WHERE p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $1`, limit)
}
-func (s *Store) PostByID(ctx context.Context, blogID, id int64) (*Post, error) {
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
- WHERE g.blog_id=$1 AND p.id=$2`, blogID, id))
+func (bs *BlogStore) PostByID(ctx context.Context, id int64) (*Post, error) {
+ return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id))
}
-func (s *Store) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
+func (bs *BlogStore) PublishedPostBySlug(ctx context.Context, pageID int64, slug string) (*Post, error) {
+ return scanPost(bs.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id
WHERE p.page_id=$1 AND p.slug=$2 AND p.published`, pageID, slug))
}
-func (s *Store) CreatePost(ctx context.Context, p *Post) (*Post, error) {
+func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) {
var id int64
- err := s.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
+ err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published).Scan(&id)
if err != nil {
return nil, wrap(err)
}
- return scanPost(s.db.QueryRow(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id WHERE p.id=$1`, id))
+ return bs.PostByID(ctx, id)
}
-// UpdatePost updates a post; the page must belong to the same blog (checked by handler).
-func (s *Store) UpdatePost(ctx context.Context, p *Post) error {
- _, err := s.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`,
+func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error {
+ _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, updated_at=now() WHERE id=$1`,
p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published)
return wrap(err)
}
-func (s *Store) DeletePost(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM posts p USING pages g WHERE g.id=p.page_id AND g.blog_id=$1 AND p.id=$2`, blogID, id)
+func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id)
return err
}
diff --git a/internal/store/sections.go b/internal/store/sections.go
index caac251..2f742f7 100644
--- a/internal/store/sections.go
+++ b/internal/store/sections.go
@@ -9,7 +9,6 @@ import (
// Section is an announcement shown on every page and post of a blog.
type Section struct {
ID int64
- BlogID int64
Title string
BodyMD string
BodyHTML string
@@ -25,19 +24,19 @@ type Section struct {
func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c }
func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p }
-const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+const sectionCols = `id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
var s Section
- err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
return &s, nil
}
-func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
- rows, err := s.db.Query(ctx, q, args...)
+func (bs *BlogStore) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
+ rows, err := bs.db.Query(ctx, q, args...)
if err != nil {
return nil, err
}
@@ -53,44 +52,44 @@ func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]S
return out, rows.Err()
}
-func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) ListSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections ORDER BY sort_order, id`)
}
// EnabledSections is what the public blog renders.
-func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) {
- return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID)
+func (bs *BlogStore) EnabledSections(ctx context.Context) ([]Section, error) {
+ return bs.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE enabled ORDER BY sort_order, id`)
}
-func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id))
+func (bs *BlogStore) SectionByID(ctx context.Context, id int64) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE id=$1`, id))
}
-func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
- return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols,
- sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+func (bs *BlogStore) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
+ return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols,
+ sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
}
-func (s *Store) UpdateSection(ctx context.Context, sec *Section) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`,
- sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+func (bs *BlogStore) UpdateSection(ctx context.Context, sec *Section) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, placement=$5, style=$6, enabled=$7, updated_at=now() WHERE id=$1`,
+ sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
return wrap(err)
}
-func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error {
- _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on)
+func (bs *BlogStore) SetSectionEnabled(ctx context.Context, id int64, on bool) error {
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET enabled=$2, updated_at=now() WHERE id=$1`, id, on)
return err
}
-func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)
+func (bs *BlogStore) DeleteSection(ctx context.Context, id int64) error {
+ _, err := bs.db.Exec(ctx, `DELETE FROM sections WHERE id=$1`, id)
return err
}
// MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down).
-func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error {
- secs, err := s.ListSections(ctx, blogID)
+func (bs *BlogStore) MoveSection(ctx context.Context, id int64, dir int) error {
+ secs, err := bs.ListSections(ctx)
if err != nil {
return err
}
@@ -105,13 +104,13 @@ func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) erro
return nil
}
secs[idx], secs[j] = secs[j], secs[idx]
- tx, err := s.db.Begin(ctx)
+ tx, err := bs.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for i, sec := range secs { // renumber everything; keeps orders dense
- if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$2 WHERE id=$1`, sec.ID, i); err != nil {
return err
}
}
diff --git a/internal/store/store.go b/internal/store/store.go
index 472bb2a..c8e97ea 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -1,9 +1,15 @@
// Package store holds the data models and all SQL queries.
+//
+// Store talks to the control database (users, blog registry) and hands out a
+// BlogStore per blog, which is bound to that blog's own database — nothing a
+// BlogStore does can reach another blog's rows.
package store
import (
+ "context"
"errors"
+ "github.com/gramanas/blogspace/internal/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
@@ -13,10 +19,36 @@ var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("already exists")
type Store struct {
+ db *pgxpool.Pool // control database
+ cluster *db.Cluster
+}
+
+func New(cluster *db.Cluster) *Store { return &Store{db: cluster.Control(), cluster: cluster} }
+
+// BlogStore runs the content queries of one blog against its database.
+type BlogStore struct {
db *pgxpool.Pool
}
-func New(db *pgxpool.Pool) *Store { return &Store{db: db} }
+// Open returns the blog's store and fills in the settings (title, tagline,
+// theme) kept in the blog database.
+func (s *Store) Open(ctx context.Context, b *Blog) (*BlogStore, error) {
+ pool, err := s.cluster.Blog(ctx, b.DBName)
+ if err != nil {
+ return nil, err
+ }
+ bs := &BlogStore{db: pool}
+ if err := bs.loadSettings(ctx, b); err != nil {
+ // A database restored underneath a running app (dropdb --force,
+ // createdb, psql < dump) leaves the pool holding dead connections;
+ // drop them and try once more before giving up.
+ pool.Reset()
+ if err := bs.loadSettings(ctx, b); err != nil {
+ return nil, err
+ }
+ }
+ return bs, nil
+}
// wrap maps driver errors onto the store's sentinel errors.
func wrap(err error) error {
@@ -32,3 +64,8 @@ func wrap(err error) error {
}
return err
}
+
+// querier is what the helpers shared with transactions need: the pool or a pgx.Tx.
+type querier interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+}
diff --git a/internal/store/users.go b/internal/store/users.go
index 3ea9bdb..0c47c24 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -2,6 +2,7 @@ package store
import (
"context"
+ "errors"
"time"
)
@@ -69,9 +70,21 @@ func (s *Store) SetUserDisabled(ctx context.Context, id int64, disabled bool) er
return err
}
+// DeleteUser removes the user, their registry row and their blog database.
+// The registry goes first: a stale blog database is harmless and can be
+// dropped by hand, a registry row without a database is not.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
- _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
- return err
+ b, err := s.BlogByOwner(ctx, id)
+ if err != nil && !errors.Is(err, ErrNotFound) {
+ return err
+ }
+ if _, err := s.db.Exec(ctx, `DELETE FROM users WHERE id=$1`, id); err != nil {
+ return err
+ }
+ if b != nil {
+ return s.cluster.DropBlogDB(ctx, b.DBName)
+ }
+ return nil
}
// UserWithBlog is a row for the admin overview.
@@ -79,12 +92,11 @@ type UserWithBlog struct {
User
BlogID *int64
Subdomain *string
- BlogTitle *string
}
func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
rows, err := s.db.Query(ctx, `SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.token_version, u.created_at,
- b.id, b.subdomain, b.title
+ b.id, b.subdomain
FROM users u LEFT JOIN blogs b ON b.owner_id = u.id
ORDER BY u.role, u.username`)
if err != nil {
@@ -95,7 +107,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]UserWithBlog, error) {
for rows.Next() {
var r UserWithBlog
if err := rows.Scan(&r.ID, &r.Username, &r.PasswordHash, &r.Role, &r.Disabled, &r.TokenVersion, &r.CreatedAt,
- &r.BlogID, &r.Subdomain, &r.BlogTitle); err != nil {
+ &r.BlogID, &r.Subdomain); err != nil {
return nil, err
}
out = append(out, r)